@ansonlai/docx-redline-js 0.8.2 → 0.8.4

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