@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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fallback helpers for structural list conversion when a redline is a
|
|
3
|
+
* text no-op but the target is marker-prefixed plain text (for example `1. X`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createParser, createSerializer } from '../adapters/xml-adapter.js';
|
|
7
|
+
import { getXmlParseError } from '../core/xml-query.js';
|
|
8
|
+
import {
|
|
9
|
+
getDocumentParagraphNodes,
|
|
10
|
+
normalizeWhitespaceForTargeting
|
|
11
|
+
} from '../core/paragraph-targeting.js';
|
|
12
|
+
import { getParagraphListInfo } from '../core/list-targeting.js';
|
|
13
|
+
import { ReconciliationPipeline } from '../pipeline/pipeline.js';
|
|
14
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
15
|
+
import { parseMarkdownListContent, hasListItems } from './list-parsing.js';
|
|
16
|
+
import { inferNumberingStyleFromMarker } from './list-markdown.js';
|
|
17
|
+
|
|
18
|
+
function parseSingleLineListCandidate(text) {
|
|
19
|
+
const rawText = String(text || '');
|
|
20
|
+
if (!rawText.trim()) return null;
|
|
21
|
+
if (rawText.includes('\n')) return null;
|
|
22
|
+
|
|
23
|
+
const parsed = parseMarkdownListContent(rawText);
|
|
24
|
+
if (!parsed || !hasListItems(parsed) || !Array.isArray(parsed.items) || parsed.items.length !== 1) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const item = parsed.items[0];
|
|
29
|
+
if (!item || (item.type !== 'numbered' && item.type !== 'bullet')) return null;
|
|
30
|
+
|
|
31
|
+
const marker = String(item.marker || '').trim();
|
|
32
|
+
const numberingStyle = item.type === 'numbered'
|
|
33
|
+
? inferNumberingStyleFromMarker(marker || '1.')
|
|
34
|
+
: 'bullet';
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
type: item.type,
|
|
38
|
+
marker,
|
|
39
|
+
numberingStyle,
|
|
40
|
+
startAt: parseMarkerStart(marker, numberingStyle),
|
|
41
|
+
contentText: String(item.text || '').trim(),
|
|
42
|
+
normalizedContent: normalizeWhitespaceForTargeting(String(item.text || ''))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Removes the leading single-line list marker from text when present.
|
|
48
|
+
*
|
|
49
|
+
* Example:
|
|
50
|
+
* - `1. HEADER` -> `HEADER`
|
|
51
|
+
* - `2.2.1. Clause` -> `Clause`
|
|
52
|
+
*
|
|
53
|
+
* @param {string} text - Candidate single-line list text
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function stripSingleLineListMarkerPrefix(text) {
|
|
57
|
+
const candidate = parseSingleLineListCandidate(text);
|
|
58
|
+
if (!candidate) return String(text || '').trim();
|
|
59
|
+
return String(candidate.contentText || '').trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseMarkerStart(marker, numberingStyle) {
|
|
63
|
+
if (numberingStyle !== 'decimal') return null;
|
|
64
|
+
const match = String(marker || '').trim().match(/^(\d+)\.?$/);
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
const value = Number.parseInt(match[1], 10);
|
|
67
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolves how a caller should handle numbering ownership for a single-line
|
|
72
|
+
* structural list fallback across multi-operation runs.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* numberingKey?: string|null,
|
|
76
|
+
* startAt?: number|null
|
|
77
|
+
* }|null} plan - Single-line fallback plan
|
|
78
|
+
* @param {{
|
|
79
|
+
* explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
|
|
80
|
+
* }|null} sequenceState - Optional mutable sequence state
|
|
81
|
+
* @returns {{
|
|
82
|
+
* type: 'none'|'sharedByStyle'|'explicitReuse'|'explicitStartNew'|'explicitIsolated',
|
|
83
|
+
* numberingKey: string|null,
|
|
84
|
+
* startAt: number|null,
|
|
85
|
+
* numId: string|null
|
|
86
|
+
* }}
|
|
87
|
+
*/
|
|
88
|
+
export function resolveSingleLineListFallbackNumberingAction(plan, sequenceState = null) {
|
|
89
|
+
const numberingKey = plan?.numberingKey ? String(plan.numberingKey) : null;
|
|
90
|
+
const startAt = Number.isInteger(plan?.startAt) && plan.startAt > 0 ? plan.startAt : null;
|
|
91
|
+
|
|
92
|
+
if (!numberingKey) {
|
|
93
|
+
return {
|
|
94
|
+
type: 'none',
|
|
95
|
+
numberingKey: null,
|
|
96
|
+
startAt,
|
|
97
|
+
numId: null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (startAt == null) {
|
|
102
|
+
return {
|
|
103
|
+
type: 'sharedByStyle',
|
|
104
|
+
numberingKey,
|
|
105
|
+
startAt: null,
|
|
106
|
+
numId: null
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const explicitMap = sequenceState?.explicitByNumberingKey;
|
|
111
|
+
if (!(explicitMap instanceof Map)) {
|
|
112
|
+
return {
|
|
113
|
+
type: 'explicitIsolated',
|
|
114
|
+
numberingKey,
|
|
115
|
+
startAt,
|
|
116
|
+
numId: null
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existing = explicitMap.get(numberingKey) || null;
|
|
121
|
+
if (
|
|
122
|
+
existing &&
|
|
123
|
+
existing.numId != null &&
|
|
124
|
+
Number.isInteger(existing.nextStartAt) &&
|
|
125
|
+
existing.nextStartAt === startAt
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
type: 'explicitReuse',
|
|
129
|
+
numberingKey,
|
|
130
|
+
startAt,
|
|
131
|
+
numId: String(existing.numId)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
type: 'explicitStartNew',
|
|
137
|
+
numberingKey,
|
|
138
|
+
startAt,
|
|
139
|
+
numId: null
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Records/advances explicit numeric single-line sequence state.
|
|
145
|
+
*
|
|
146
|
+
* @param {{
|
|
147
|
+
* explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
|
|
148
|
+
* }|null} sequenceState - Mutable sequence state
|
|
149
|
+
* @param {string|null} numberingKey - Numbering signature key
|
|
150
|
+
* @param {string|number|null} numId - Sequence numId
|
|
151
|
+
* @param {number|null} startAt - Current explicit start marker
|
|
152
|
+
*/
|
|
153
|
+
export function recordSingleLineListFallbackExplicitSequence(sequenceState, numberingKey, numId, startAt) {
|
|
154
|
+
if (!sequenceState || !(sequenceState.explicitByNumberingKey instanceof Map)) return;
|
|
155
|
+
if (!numberingKey || numId == null || !Number.isInteger(startAt) || startAt < 1) return;
|
|
156
|
+
|
|
157
|
+
sequenceState.explicitByNumberingKey.set(String(numberingKey), {
|
|
158
|
+
numId: String(numId),
|
|
159
|
+
nextStartAt: startAt + 1
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clears explicit sequence tracking for a numbering signature.
|
|
165
|
+
*
|
|
166
|
+
* @param {{
|
|
167
|
+
* explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
|
|
168
|
+
* }|null} sequenceState - Mutable sequence state
|
|
169
|
+
* @param {string|null} numberingKey - Numbering signature key
|
|
170
|
+
*/
|
|
171
|
+
export function clearSingleLineListFallbackExplicitSequence(sequenceState, numberingKey) {
|
|
172
|
+
if (!sequenceState || !(sequenceState.explicitByNumberingKey instanceof Map)) return;
|
|
173
|
+
if (!numberingKey) return;
|
|
174
|
+
sequenceState.explicitByNumberingKey.delete(String(numberingKey));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getDirectWordChild(element, localName) {
|
|
178
|
+
if (!element) return null;
|
|
179
|
+
return Array.from(element.childNodes || []).find(
|
|
180
|
+
node =>
|
|
181
|
+
node &&
|
|
182
|
+
node.nodeType === 1 &&
|
|
183
|
+
node.namespaceURI === 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' &&
|
|
184
|
+
node.localName === localName
|
|
185
|
+
) || null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Forces explicit list bindings on paragraph nodes.
|
|
190
|
+
*
|
|
191
|
+
* Useful when callers need deterministic `w:numPr` assignment regardless of
|
|
192
|
+
* incoming generator quirks or paragraph property tracked-change payloads.
|
|
193
|
+
*
|
|
194
|
+
* @param {Node[]|null|undefined} nodes - Candidate nodes containing paragraphs
|
|
195
|
+
* @param {{
|
|
196
|
+
* numId: string|number,
|
|
197
|
+
* ilvl?: number,
|
|
198
|
+
* clearParagraphPropertyChanges?: boolean,
|
|
199
|
+
* removeListPropertyNode?: boolean
|
|
200
|
+
* }} options - Binding options
|
|
201
|
+
* @returns {number} Number of paragraph nodes updated
|
|
202
|
+
*/
|
|
203
|
+
export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
|
|
204
|
+
const numId = options?.numId;
|
|
205
|
+
if (numId == null) return 0;
|
|
206
|
+
const ilvl = Number.isInteger(options?.ilvl) ? Math.max(0, options.ilvl) : 0;
|
|
207
|
+
const clearParagraphPropertyChanges = options?.clearParagraphPropertyChanges !== false;
|
|
208
|
+
const removeListPropertyNode = options?.removeListPropertyNode !== false;
|
|
209
|
+
|
|
210
|
+
const paragraphs = (Array.isArray(nodes) ? nodes : [])
|
|
211
|
+
.filter(node => node && node.nodeType === 1 && node.localName === 'p');
|
|
212
|
+
let updated = 0;
|
|
213
|
+
|
|
214
|
+
for (const paragraph of paragraphs) {
|
|
215
|
+
const ownerDoc = paragraph.ownerDocument;
|
|
216
|
+
if (!ownerDoc) continue;
|
|
217
|
+
|
|
218
|
+
let pPr = getDirectWordChild(paragraph, 'pPr');
|
|
219
|
+
if (!pPr) {
|
|
220
|
+
pPr = ownerDoc.createElementNS(
|
|
221
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
222
|
+
'w:pPr'
|
|
223
|
+
);
|
|
224
|
+
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (clearParagraphPropertyChanges) {
|
|
228
|
+
const pPrChange = getDirectWordChild(pPr, 'pPrChange');
|
|
229
|
+
if (pPrChange) pPr.removeChild(pPrChange);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (removeListPropertyNode) {
|
|
233
|
+
const listPr = getDirectWordChild(pPr, 'listPr');
|
|
234
|
+
if (listPr) pPr.removeChild(listPr);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let numPr = getDirectWordChild(pPr, 'numPr');
|
|
238
|
+
if (!numPr) {
|
|
239
|
+
numPr = ownerDoc.createElementNS(
|
|
240
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
241
|
+
'w:numPr'
|
|
242
|
+
);
|
|
243
|
+
pPr.appendChild(numPr);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let ilvlEl = getDirectWordChild(numPr, 'ilvl');
|
|
247
|
+
if (!ilvlEl) {
|
|
248
|
+
ilvlEl = ownerDoc.createElementNS(
|
|
249
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
250
|
+
'w:ilvl'
|
|
251
|
+
);
|
|
252
|
+
numPr.appendChild(ilvlEl);
|
|
253
|
+
}
|
|
254
|
+
ilvlEl.setAttribute('w:val', String(ilvl));
|
|
255
|
+
|
|
256
|
+
let numIdEl = getDirectWordChild(numPr, 'numId');
|
|
257
|
+
if (!numIdEl) {
|
|
258
|
+
numIdEl = ownerDoc.createElementNS(
|
|
259
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
260
|
+
'w:numId'
|
|
261
|
+
);
|
|
262
|
+
numPr.appendChild(numIdEl);
|
|
263
|
+
}
|
|
264
|
+
numIdEl.setAttribute('w:val', String(numId));
|
|
265
|
+
updated++;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return updated;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function getFirstParagraphFromOxml(oxml) {
|
|
272
|
+
const parser = createParser();
|
|
273
|
+
const doc = parser.parseFromString(String(oxml || ''), 'application/xml');
|
|
274
|
+
const parseError = getXmlParseError(doc);
|
|
275
|
+
if (parseError) return null;
|
|
276
|
+
const paragraphs = getDocumentParagraphNodes(doc);
|
|
277
|
+
return paragraphs[0] || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function trimTrailingBlankParagraph(oxml) {
|
|
281
|
+
if (!oxml) return '';
|
|
282
|
+
return String(oxml).replace(/<w:p>\s*<w:pPr>\s*<\/w:pPr>\s*<\/w:p>\s*$/i, '');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getAttributeFirst(element, names) {
|
|
286
|
+
for (const name of names) {
|
|
287
|
+
const value = element.getAttribute(name);
|
|
288
|
+
if (value != null && value !== '') return value;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getElementId(element, names) {
|
|
294
|
+
const raw = getAttributeFirst(element, names);
|
|
295
|
+
const parsed = Number.parseInt(String(raw || ''), 10);
|
|
296
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setElementVal(element, value) {
|
|
300
|
+
element.setAttribute('w:val', String(value));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function extractFirstParagraphNumIdFromOxml(oxml) {
|
|
304
|
+
const parser = createParser();
|
|
305
|
+
const doc = parser.parseFromString(String(oxml || ''), 'application/xml');
|
|
306
|
+
const parseError = getXmlParseError(doc);
|
|
307
|
+
if (parseError) return null;
|
|
308
|
+
|
|
309
|
+
const paragraphs = getDocumentParagraphNodes(doc);
|
|
310
|
+
const firstParagraph = paragraphs[0] || null;
|
|
311
|
+
if (!firstParagraph) return null;
|
|
312
|
+
|
|
313
|
+
const numIdNodes = Array.from(firstParagraph.getElementsByTagNameNS('*', 'numId'));
|
|
314
|
+
for (const numIdNode of numIdNodes) {
|
|
315
|
+
const numId = getElementId(numIdNode, ['w:val', 'val']);
|
|
316
|
+
if (numId != null) return String(numId);
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function applyStartOverrideToNumberingXml(numberingXml, targetNumId, startAt, options = {}) {
|
|
322
|
+
if (!numberingXml || !targetNumId || !Number.isInteger(startAt) || startAt < 1) return numberingXml;
|
|
323
|
+
const setAbstractStartOverride = options.setAbstractStartOverride !== false;
|
|
324
|
+
const parser = createParser();
|
|
325
|
+
const serializer = createSerializer();
|
|
326
|
+
const numberingDoc = parser.parseFromString(String(numberingXml || ''), 'application/xml');
|
|
327
|
+
const parseError = getXmlParseError(numberingDoc);
|
|
328
|
+
if (parseError) return numberingXml;
|
|
329
|
+
|
|
330
|
+
const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
|
|
331
|
+
const target = nums.find(node => {
|
|
332
|
+
const id = getElementId(node, ['w:numId', 'numId']);
|
|
333
|
+
return id != null && String(id) === String(targetNumId);
|
|
334
|
+
});
|
|
335
|
+
if (!target) return numberingXml;
|
|
336
|
+
|
|
337
|
+
const abstractNumIdNode = Array.from(target.getElementsByTagNameNS('*', 'abstractNumId'))[0] || null;
|
|
338
|
+
const abstractNumId = getElementId(abstractNumIdNode, ['w:val', 'val']);
|
|
339
|
+
|
|
340
|
+
let lvlOverride = Array.from(target.getElementsByTagNameNS('*', 'lvlOverride'))
|
|
341
|
+
.find(node => {
|
|
342
|
+
const ilvl = getElementId(node, ['w:ilvl', 'ilvl']);
|
|
343
|
+
return ilvl === 0;
|
|
344
|
+
}) || null;
|
|
345
|
+
if (!lvlOverride) {
|
|
346
|
+
lvlOverride = numberingDoc.createElementNS(
|
|
347
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
348
|
+
'w:lvlOverride'
|
|
349
|
+
);
|
|
350
|
+
lvlOverride.setAttribute('w:ilvl', '0');
|
|
351
|
+
target.appendChild(lvlOverride);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let startOverride = Array.from(lvlOverride.getElementsByTagNameNS('*', 'startOverride'))[0] || null;
|
|
355
|
+
if (!startOverride) {
|
|
356
|
+
startOverride = numberingDoc.createElementNS(
|
|
357
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
358
|
+
'w:startOverride'
|
|
359
|
+
);
|
|
360
|
+
lvlOverride.appendChild(startOverride);
|
|
361
|
+
}
|
|
362
|
+
setElementVal(startOverride, startAt);
|
|
363
|
+
|
|
364
|
+
// Optional compatibility mode: set abstract-level <w:start> in addition to
|
|
365
|
+
// num-level <w:startOverride>. This can influence other lists sharing that
|
|
366
|
+
// abstract definition in some renderers, so callers may disable it.
|
|
367
|
+
if (setAbstractStartOverride && abstractNumId != null) {
|
|
368
|
+
const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
|
|
369
|
+
const abstractNum = abstractNums.find(node => {
|
|
370
|
+
const id = getElementId(node, ['w:abstractNumId', 'abstractNumId']);
|
|
371
|
+
return id != null && id === abstractNumId;
|
|
372
|
+
}) || null;
|
|
373
|
+
if (abstractNum) {
|
|
374
|
+
let lvl = Array.from(abstractNum.getElementsByTagNameNS('*', 'lvl'))
|
|
375
|
+
.find(node => {
|
|
376
|
+
const ilvl = getElementId(node, ['w:ilvl', 'ilvl']);
|
|
377
|
+
return ilvl === 0;
|
|
378
|
+
}) || null;
|
|
379
|
+
if (!lvl) {
|
|
380
|
+
lvl = numberingDoc.createElementNS(
|
|
381
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
382
|
+
'w:lvl'
|
|
383
|
+
);
|
|
384
|
+
lvl.setAttribute('w:ilvl', '0');
|
|
385
|
+
abstractNum.appendChild(lvl);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let startNode = Array.from(lvl.getElementsByTagNameNS('*', 'start'))[0] || null;
|
|
389
|
+
if (!startNode) {
|
|
390
|
+
startNode = numberingDoc.createElementNS(
|
|
391
|
+
'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
392
|
+
'w:start'
|
|
393
|
+
);
|
|
394
|
+
lvl.insertBefore(startNode, lvl.firstChild);
|
|
395
|
+
}
|
|
396
|
+
setElementVal(startNode, startAt);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return serializer.serializeToString(numberingDoc);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Builds a plan for single-line structural list fallback.
|
|
405
|
+
*
|
|
406
|
+
* @param {{
|
|
407
|
+
* oxml: string,
|
|
408
|
+
* originalText: string,
|
|
409
|
+
* modifiedText: string,
|
|
410
|
+
* allowExistingList?: boolean
|
|
411
|
+
* }} options - Input values for fallback detection
|
|
412
|
+
* @returns {{ listInput: string, numberingKey: string, originalText: string, wasListParagraph: boolean, startAt: number|null }|null}
|
|
413
|
+
*/
|
|
414
|
+
export function buildSingleLineListStructuralFallbackPlan(options = {}) {
|
|
415
|
+
const oxml = String(options.oxml || '');
|
|
416
|
+
const originalText = String(options.originalText || '');
|
|
417
|
+
const modifiedText = String(options.modifiedText || '');
|
|
418
|
+
const allowExistingList = options.allowExistingList === true;
|
|
419
|
+
|
|
420
|
+
if (!oxml.trim() || !modifiedText.trim()) return null;
|
|
421
|
+
|
|
422
|
+
const paragraph = getFirstParagraphFromOxml(oxml);
|
|
423
|
+
if (!paragraph) return null;
|
|
424
|
+
|
|
425
|
+
const existingListInfo = getParagraphListInfo(paragraph);
|
|
426
|
+
if (existingListInfo && !allowExistingList) return null;
|
|
427
|
+
|
|
428
|
+
const modifiedCleanText = preprocessMarkdown(modifiedText).cleanText || modifiedText;
|
|
429
|
+
const modifiedCandidate = parseSingleLineListCandidate(modifiedText)
|
|
430
|
+
|| parseSingleLineListCandidate(modifiedCleanText);
|
|
431
|
+
if (!modifiedCandidate) return null;
|
|
432
|
+
|
|
433
|
+
const currentCandidate = parseSingleLineListCandidate(originalText);
|
|
434
|
+
const sameRawText =
|
|
435
|
+
normalizeWhitespaceForTargeting(originalText) === normalizeWhitespaceForTargeting(modifiedCleanText);
|
|
436
|
+
const sameListText = !!currentCandidate
|
|
437
|
+
&& currentCandidate.type === modifiedCandidate.type
|
|
438
|
+
&& currentCandidate.normalizedContent === modifiedCandidate.normalizedContent;
|
|
439
|
+
if (!sameRawText && !sameListText) return null;
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
listInput: `${modifiedCandidate.marker} ${modifiedCandidate.contentText}`.trim(),
|
|
443
|
+
numberingKey: `${modifiedCandidate.type}:${modifiedCandidate.numberingStyle}:single`,
|
|
444
|
+
originalText,
|
|
445
|
+
wasListParagraph: !!existingListInfo,
|
|
446
|
+
startAt: modifiedCandidate.startAt
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Executes a single-line structural list fallback plan.
|
|
452
|
+
*
|
|
453
|
+
* Returns list fragment OOXML (not wrapped package), plus `numberingXml`.
|
|
454
|
+
*
|
|
455
|
+
* @param {{ listInput: string, numberingKey: string, originalText?: string, startAt?: number|null }} plan - Fallback plan
|
|
456
|
+
* @param {{
|
|
457
|
+
* author?: string,
|
|
458
|
+
* generateRedlines?: boolean,
|
|
459
|
+
* pipeline?: ReconciliationPipeline,
|
|
460
|
+
* setAbstractStartOverride?: boolean
|
|
461
|
+
* }} [options={}] - Execution options
|
|
462
|
+
* @returns {Promise<{
|
|
463
|
+
* hasChanges: boolean,
|
|
464
|
+
* oxml: string,
|
|
465
|
+
* numberingXml: string|null,
|
|
466
|
+
* includeNumbering: boolean,
|
|
467
|
+
* listStructuralFallbackApplied: boolean,
|
|
468
|
+
* listStructuralFallbackKey: string|null,
|
|
469
|
+
* warnings: string[]
|
|
470
|
+
* }>}
|
|
471
|
+
*/
|
|
472
|
+
export async function executeSingleLineListStructuralFallback(plan, options = {}) {
|
|
473
|
+
if (!plan || !plan.listInput) {
|
|
474
|
+
return {
|
|
475
|
+
hasChanges: false,
|
|
476
|
+
oxml: '',
|
|
477
|
+
numberingXml: null,
|
|
478
|
+
includeNumbering: false,
|
|
479
|
+
listStructuralFallbackApplied: false,
|
|
480
|
+
listStructuralFallbackKey: null,
|
|
481
|
+
warnings: ['Single-line list fallback plan missing']
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const author = options.author || 'AI';
|
|
486
|
+
const generateRedlines = options.generateRedlines ?? true;
|
|
487
|
+
const pipeline = options.pipeline || new ReconciliationPipeline({ author, generateRedlines });
|
|
488
|
+
|
|
489
|
+
const result = await pipeline.executeListGeneration(
|
|
490
|
+
plan.listInput,
|
|
491
|
+
null,
|
|
492
|
+
null,
|
|
493
|
+
String(plan.originalText || '')
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const rawOxml = result?.oxml || result?.ooxml || '';
|
|
497
|
+
const oxml = trimTrailingBlankParagraph(rawOxml);
|
|
498
|
+
const generatedNumId = extractFirstParagraphNumIdFromOxml(oxml);
|
|
499
|
+
const numberingXmlWithStart = applyStartOverrideToNumberingXml(
|
|
500
|
+
result?.numberingXml || null,
|
|
501
|
+
generatedNumId,
|
|
502
|
+
Number.isInteger(plan?.startAt) ? plan.startAt : null,
|
|
503
|
+
{
|
|
504
|
+
setAbstractStartOverride: options.setAbstractStartOverride
|
|
505
|
+
}
|
|
506
|
+
);
|
|
507
|
+
const isValid = result?.isValid !== false;
|
|
508
|
+
if (!oxml || !isValid) {
|
|
509
|
+
return {
|
|
510
|
+
hasChanges: false,
|
|
511
|
+
oxml: '',
|
|
512
|
+
numberingXml: null,
|
|
513
|
+
includeNumbering: false,
|
|
514
|
+
listStructuralFallbackApplied: false,
|
|
515
|
+
listStructuralFallbackKey: plan.numberingKey || null,
|
|
516
|
+
warnings: ['Single-line list fallback produced no valid OOXML']
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
hasChanges: true,
|
|
522
|
+
oxml,
|
|
523
|
+
numberingXml: numberingXmlWithStart,
|
|
524
|
+
includeNumbering: true,
|
|
525
|
+
listStructuralFallbackApplied: true,
|
|
526
|
+
listStructuralFallbackKey: plan.numberingKey || null,
|
|
527
|
+
listStructuralFallbackStartAt: Number.isInteger(plan?.startAt) ? plan.startAt : null,
|
|
528
|
+
warnings: ['Single-line list structural fallback applied']
|
|
529
|
+
};
|
|
530
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts add-in redline tool payloads into shared standalone redline operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function normalizeEscapes(content) {
|
|
6
|
+
if (content == null) return '';
|
|
7
|
+
return String(content)
|
|
8
|
+
.replace(/\\n/g, '\n')
|
|
9
|
+
.replace(/\\t/g, '\t')
|
|
10
|
+
.replace(/\\r/g, '\r');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toNonNegativeInteger(value, fallback = 0) {
|
|
14
|
+
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
15
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Applies one substring replacement against source text.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} sourceText
|
|
22
|
+
* @param {string} searchText
|
|
23
|
+
* @param {string} replacementText
|
|
24
|
+
* @returns {{ applied: boolean, modifiedText: string, matchMode: 'exact'|'case_insensitive'|null }}
|
|
25
|
+
*/
|
|
26
|
+
export function applySubstringSearchReplace(sourceText, searchText, replacementText) {
|
|
27
|
+
const source = String(sourceText || '');
|
|
28
|
+
const search = String(searchText || '');
|
|
29
|
+
const replacement = String(replacementText || '');
|
|
30
|
+
if (!search) {
|
|
31
|
+
return {
|
|
32
|
+
applied: false,
|
|
33
|
+
modifiedText: source,
|
|
34
|
+
matchMode: null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const exactIndex = source.indexOf(search);
|
|
39
|
+
if (exactIndex >= 0) {
|
|
40
|
+
return {
|
|
41
|
+
applied: true,
|
|
42
|
+
modifiedText: `${source.slice(0, exactIndex)}${replacement}${source.slice(exactIndex + search.length)}`,
|
|
43
|
+
matchMode: 'exact'
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lowerSource = source.toLowerCase();
|
|
48
|
+
const lowerSearch = search.toLowerCase();
|
|
49
|
+
const insensitiveIndex = lowerSource.indexOf(lowerSearch);
|
|
50
|
+
if (insensitiveIndex >= 0) {
|
|
51
|
+
return {
|
|
52
|
+
applied: true,
|
|
53
|
+
modifiedText: `${source.slice(0, insensitiveIndex)}${replacement}${source.slice(insensitiveIndex + search.length)}`,
|
|
54
|
+
matchMode: 'case_insensitive'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
applied: false,
|
|
60
|
+
modifiedText: source,
|
|
61
|
+
matchMode: null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds a shared redline operation for a scoped paragraph/range OOXML document.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} change - Add-in tool payload
|
|
69
|
+
* @param {{
|
|
70
|
+
* scopeStartText?: string,
|
|
71
|
+
* scopeParagraphCount?: number,
|
|
72
|
+
* insertionBeforeStart?: boolean
|
|
73
|
+
* }} [context={}]
|
|
74
|
+
* @returns {{ ok: true, operation: Object } | { ok: false, reason: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function toScopedSharedRedlineOperation(change, context = {}) {
|
|
77
|
+
const operationName = String(change?.operation || '').trim().toLowerCase();
|
|
78
|
+
const scopeStartText = String(context.scopeStartText || '').trim();
|
|
79
|
+
const scopeParagraphCount = Math.max(1, toNonNegativeInteger(context.scopeParagraphCount, 1));
|
|
80
|
+
const insertionBeforeStart = context.insertionBeforeStart === true;
|
|
81
|
+
|
|
82
|
+
if (!scopeStartText) {
|
|
83
|
+
return { ok: false, reason: 'Target paragraph text is empty; cannot build shared redline target.' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!operationName) {
|
|
87
|
+
return { ok: false, reason: 'Missing change.operation value.' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let modifiedText = null;
|
|
91
|
+
if (operationName === 'edit_paragraph') {
|
|
92
|
+
if (change?.newContent == null) {
|
|
93
|
+
return { ok: false, reason: 'Missing newContent for edit_paragraph.' };
|
|
94
|
+
}
|
|
95
|
+
modifiedText = normalizeEscapes(change.newContent);
|
|
96
|
+
} else if (operationName === 'replace_paragraph' || operationName === 'replace_range') {
|
|
97
|
+
const replacementContent = change?.content ?? change?.newContent ?? change?.replacementText;
|
|
98
|
+
if (replacementContent == null) {
|
|
99
|
+
return { ok: false, reason: `Missing content for ${operationName}.` };
|
|
100
|
+
}
|
|
101
|
+
const normalizedReplacement = normalizeEscapes(replacementContent);
|
|
102
|
+
if (operationName === 'replace_range' && insertionBeforeStart) {
|
|
103
|
+
// Model occasionally emits an insertion-at-start shape as:
|
|
104
|
+
// replace_range(P1..P0). Normalize to "insert paragraph before P1"
|
|
105
|
+
// by preserving P1 text and prefixing inserted content + paragraph break.
|
|
106
|
+
modifiedText = scopeStartText
|
|
107
|
+
? `${normalizedReplacement}\n${scopeStartText}`
|
|
108
|
+
: normalizedReplacement;
|
|
109
|
+
} else {
|
|
110
|
+
modifiedText = normalizedReplacement;
|
|
111
|
+
}
|
|
112
|
+
} else if (operationName === 'modify_text') {
|
|
113
|
+
const searchText = normalizeEscapes(change?.originalText || '');
|
|
114
|
+
if (!searchText.trim()) {
|
|
115
|
+
return { ok: false, reason: 'Missing originalText for modify_text.' };
|
|
116
|
+
}
|
|
117
|
+
const replacementText = normalizeEscapes(change?.replacementText || '');
|
|
118
|
+
const replacement = applySubstringSearchReplace(scopeStartText, searchText, replacementText);
|
|
119
|
+
if (!replacement.applied) {
|
|
120
|
+
return { ok: false, reason: `Could not find modify_text originalText in target paragraph.` };
|
|
121
|
+
}
|
|
122
|
+
modifiedText = replacement.modifiedText;
|
|
123
|
+
} else {
|
|
124
|
+
return { ok: false, reason: `Unsupported redline operation: ${operationName}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const op = {
|
|
128
|
+
type: 'redline',
|
|
129
|
+
// Scoped bridge payloads are rebuilt as P1..Pn; always anchor to P1.
|
|
130
|
+
targetRef: 'P1',
|
|
131
|
+
target: scopeStartText,
|
|
132
|
+
modified: modifiedText
|
|
133
|
+
};
|
|
134
|
+
if (operationName === 'replace_range') {
|
|
135
|
+
if (scopeParagraphCount > 1) {
|
|
136
|
+
op.targetEndRef = `P${scopeParagraphCount}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ok: true, operation: op };
|
|
141
|
+
}
|