@ansonlai/docx-redline-js 0.8.3 → 0.9.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.
Files changed (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -0,0 +1,416 @@
1
+ /**
2
+ * DOM core of `list-format` `convert-plain` (canonical list operations plan, WP-4).
3
+ *
4
+ * Converts plain paragraphs IN PLACE: the paragraph element, its `w14:paraId`, bookmarks, comments and direct
5
+ * formatting stay; the typed marker (when stripped) becomes a tracked `w:del` of exactly the marker text, and the
6
+ * new `w:numPr` binding is recorded as a tracked `w:pPrChange` through the same path as
7
+ * `paragraph-format.numbering` (`snapshotAndAttachPPrChange` + `applyParagraphPropertiesToPPr`). Word's Reject All
8
+ * restores the literal marker and the unnumbered paragraph; Accept All shows a real list label.
9
+ *
10
+ * Every refusal is decided in a planning pass before anything is mutated. Current properties are read only
11
+ * through `core/current-properties.js`; no paragraph-mark revision is created (a binding is a property change).
12
+ */
13
+
14
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
15
+ import {
16
+ getCurrentChild,
17
+ getCurrentNumIdElements,
18
+ getCurrentParagraphProperties,
19
+ getDirectWordChild
20
+ } from '../core/current-properties.js';
21
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
22
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
23
+ import { createWordElement, isWordElement } from '../core/word-xml.js';
24
+ import { getParagraphListInfo } from '../core/list-targeting.js';
25
+ import { createTrackChange, snapshotAndAttachPPrChange } from '../engine/run-builders.js';
26
+ import { applyParagraphPropertiesToPPr } from '../engine/rpr-helpers.js';
27
+ import {
28
+ createRunFromPieces,
29
+ getRunContentPieces,
30
+ getRunTextLength,
31
+ sliceRunPieces
32
+ } from '../engine/surgical-run-splitting.js';
33
+ import { allocateNumberingDefinition, mergeNumberingParts } from './numbering-allocation.js';
34
+ import { createDynamicNumberingIdState } from './numbering-helpers.js';
35
+ import {
36
+ LIST_FORMAT_STYLE_NUMBERED,
37
+ LIST_FORMAT_TARGET_IS_LIST,
38
+ LIST_MARKER_AMBIGUOUS,
39
+ LIST_MARKER_NOT_FOUND,
40
+ LIST_MARKER_UNSUPPORTED,
41
+ listFormatSequenceKey,
42
+ listLevelSpec,
43
+ markerValue,
44
+ parseTypedListMarker,
45
+ resolveMarkerFormat
46
+ } from './list-format.js';
47
+
48
+ const PARAGRAPH_FORMAT_REVISION_CONFLICT = 'PARAGRAPH_FORMAT_REVISION_CONFLICT';
49
+ /** Paragraph children that carry no visible text and may precede the marker. */
50
+ const ZERO_WIDTH_CHILDREN = new Set([
51
+ 'pPr', 'bookmarkStart', 'bookmarkEnd', 'proofErr', 'commentRangeStart', 'commentRangeEnd', 'permStart', 'permEnd'
52
+ ]);
53
+ /** Run children a marker-bearing run may contain (anything else, such as a field or drawing, is refused). */
54
+ const SPLITTABLE_RUN_CHILDREN = new Set(['rPr', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen', 'lastRenderedPageBreak']);
55
+ const MARKER_RUN_CHILDREN = new Set(['rPr', 't', 'tab', 'lastRenderedPageBreak']);
56
+
57
+ const excerpt = (text, limit = 80) => {
58
+ const value = String(text ?? '');
59
+ return value.length > limit ? `${value.slice(0, limit)}...` : value;
60
+ };
61
+ const localName = node => String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
62
+ const readVal = element => element?.getAttribute?.('w:val') ?? element?.getAttribute?.('val') ?? null;
63
+
64
+ export function refusal(code, message, extra = {}, recovery = null) {
65
+ return { ok: false, error: { code, message, ...extra, ...(recovery ? { recovery } : {}) } };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------------------------------------
69
+ // Style-linked numbering
70
+ // ---------------------------------------------------------------------------------------------------------
71
+
72
+ export function parseStyles(stylesXml) {
73
+ const text = String(stylesXml || '');
74
+ if (!text.trim()) return null;
75
+ const parsed = parseOoxmlSafe(text, 'application/xml');
76
+ return parsed?.doc || null;
77
+ }
78
+
79
+ /**
80
+ * The numId a paragraph style (or its `w:basedOn` ancestors) binds, or null. A paragraph that is numbered by its
81
+ * style is already a list item; converting it would stack a second binding over the style's list.
82
+ */
83
+ export function styleNumbering(stylesDoc, styleId) {
84
+ if (!stylesDoc) return null;
85
+ const styles = Array.from(stylesDoc.getElementsByTagNameNS('*', 'style'))
86
+ .filter(style => (style.getAttribute('w:type') ?? style.getAttribute('type')) === 'paragraph');
87
+ const byId = new Map(styles.map(style => [style.getAttribute('w:styleId') ?? style.getAttribute('styleId'), style]));
88
+ let style = styleId
89
+ ? byId.get(styleId)
90
+ : styles.find(candidate => ['1', 'true', 'on'].includes(candidate.getAttribute('w:default') ?? candidate.getAttribute('default')));
91
+ for (let depth = 0; style && depth < 16; depth += 1) {
92
+ const numPr = getCurrentChild(getDirectWordChild(style, 'pPr'), 'numPr');
93
+ const numId = readVal(getCurrentChild(numPr, 'numId'));
94
+ if (numId != null) return numId === '0' ? null : { numId, styleId: style.getAttribute('w:styleId') ?? style.getAttribute('styleId') };
95
+ style = byId.get(readVal(getDirectWordChild(style, 'basedOn')));
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------------------------------------
101
+ // Marker run location
102
+ // ---------------------------------------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Finds the plain runs that carry the first `prefixLength` characters of the paragraph. Returns the run segments
106
+ * or a reason when the marker is not plain run text (inside a hyperlink, field, tracked change, ...).
107
+ */
108
+ function locateMarkerRuns(paragraph, prefixLength, expectedPrefix) {
109
+ const segments = [];
110
+ let offset = 0;
111
+ let collected = '';
112
+ for (const child of Array.from(paragraph.childNodes || [])) {
113
+ if (offset >= prefixLength) break;
114
+ if (child.nodeType !== 1) continue;
115
+ const name = localName(child);
116
+ if (isWordElement(child, 'r')) {
117
+ const pieces = getRunContentPieces(child);
118
+ const length = getRunTextLength(pieces);
119
+ if (length === 0) {
120
+ const hasContent = Array.from(child.childNodes || []).some(node => node.nodeType === 1 && !MARKER_RUN_CHILDREN.has(localName(node)));
121
+ if (hasContent) return { reason: 'run-content' };
122
+ continue;
123
+ }
124
+ const elementNames = Array.from(child.childNodes || []).filter(node => node.nodeType === 1).map(localName);
125
+ if (elementNames.some(item => !SPLITTABLE_RUN_CHILDREN.has(item))) return { reason: 'run-content' };
126
+ const overlap = Math.min(prefixLength, offset + length) - offset;
127
+ // The marker part of the run may hold only text and tabs.
128
+ let covered = 0;
129
+ for (const piece of pieces) {
130
+ if (covered >= overlap) break;
131
+ if (!isWordElement(piece.node, 't') && !isWordElement(piece.node, 'tab')) return { reason: 'run-content' };
132
+ covered = piece.end;
133
+ }
134
+ segments.push({ run: child, start: offset, end: offset + length, pieces });
135
+ collected += pieces.map(piece => piece.text).join('');
136
+ offset += length;
137
+ continue;
138
+ }
139
+ if (ZERO_WIDTH_CHILDREN.has(name)) continue;
140
+ return { reason: name === 'ins' || name === 'del' || name === 'moveTo' || name === 'moveFrom' ? 'tracked-change' : 'container' };
141
+ }
142
+ if (offset < prefixLength || collected.slice(0, prefixLength) !== expectedPrefix) return { reason: 'text-mismatch' };
143
+ return { segments };
144
+ }
145
+
146
+ /** Removes the first `prefixLength` characters: a tracked `w:del` when tracking, a direct removal otherwise. */
147
+ function stripMarkerPrefix(xmlDoc, segments, prefixLength, { author, generateRedlines, revisionIdAllocator }) {
148
+ for (const segment of segments) {
149
+ const { run, start, end, pieces } = segment;
150
+ const localEnd = Math.min(prefixLength, end) - start;
151
+ if (localEnd <= 0) continue;
152
+ const parent = run.parentNode;
153
+ const rPr = getDirectWordChild(run, 'rPr');
154
+ if (generateRedlines) {
155
+ const deletedRun = createRunFromPieces(xmlDoc, sliceRunPieces(xmlDoc, pieces, 0, localEnd, true), rPr);
156
+ parent.insertBefore(createTrackChange(xmlDoc, 'del', deletedRun, author), run);
157
+ }
158
+ if (localEnd < end - start) {
159
+ const keptRun = createRunFromPieces(xmlDoc, sliceRunPieces(xmlDoc, pieces, localEnd, end - start, false), rPr);
160
+ // The deleted copy keeps any rPrChange id; the kept copy needs its own.
161
+ if (generateRedlines) refreshRunPropertyChangeIds(keptRun, revisionIdAllocator);
162
+ parent.insertBefore(keptRun, run);
163
+ }
164
+ parent.removeChild(run);
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------------------------------------
169
+ // Numbering
170
+ // ---------------------------------------------------------------------------------------------------------
171
+
172
+ export function numIdIsLive(xmlDoc, numId) {
173
+ if (numId == null) return false;
174
+ return getCurrentNumIdElements(xmlDoc.documentElement).some(element => readVal(element) === String(numId));
175
+ }
176
+
177
+ export function sequenceMapOf(runtimeContext) {
178
+ if (!runtimeContext || typeof runtimeContext !== 'object') return null;
179
+ if (!runtimeContext.listFallbackSequenceState || typeof runtimeContext.listFallbackSequenceState !== 'object') {
180
+ runtimeContext.listFallbackSequenceState = { explicitByNumberingKey: new Map() };
181
+ }
182
+ const state = runtimeContext.listFallbackSequenceState;
183
+ if (!(state.explicitByNumberingKey instanceof Map)) state.explicitByNumberingKey = new Map();
184
+ return state.explicitByNumberingKey;
185
+ }
186
+
187
+ export function numberingIdStateOf(runtimeContext, options) {
188
+ if (runtimeContext?.numberingIdState) return runtimeContext.numberingIdState;
189
+ const state = createDynamicNumberingIdState(runtimeContext?.numberingXml || options?.numberingXml || undefined);
190
+ if (runtimeContext && typeof runtimeContext === 'object') runtimeContext.numberingIdState = state;
191
+ return state;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------------------------------------
195
+ // Plan + apply
196
+ // ---------------------------------------------------------------------------------------------------------
197
+
198
+ /**
199
+ * Converts plain paragraphs (one target or a same-container range) to list items.
200
+ *
201
+ * @param {Document} xmlDoc
202
+ * @param {Element[]} paragraphs - the resolved target range, in document order
203
+ * @param {{ format: string|null, kind: string|null, level: number, strip: 'marker'|'none', start: number|null }} request
204
+ * @param {{
205
+ * author: string, generateRedlines?: boolean, revisionIdAllocator?: object|null,
206
+ * runtimeContext?: object|null, stylesXml?: string|null, numberingXml?: string|null,
207
+ * paragraphIndexOf?: (paragraph: Element) => number
208
+ * }} context
209
+ * @returns {{ ok: true, format: string, items: object[], skipped: object[], numberingXml: string|null, allocatedNumIds: number[] }
210
+ * | { ok: false, error: object }}
211
+ */
212
+ export function convertPlainParagraphsToList(xmlDoc, paragraphs, request, context = {}) {
213
+ const generateRedlines = context.generateRedlines !== false;
214
+ const author = context.author;
215
+ const indexOf = typeof context.paragraphIndexOf === 'function' ? context.paragraphIndexOf : () => null;
216
+ const stylesDoc = parseStyles(context.stylesXml);
217
+ const strip = request.strip === 'none' ? 'none' : 'marker';
218
+
219
+ // 1. Classify every paragraph without mutating anything.
220
+ const entries = [];
221
+ const skipped = [];
222
+ for (const paragraph of paragraphs) {
223
+ const paragraphIndex = indexOf(paragraph);
224
+ const text = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
225
+ const listInfo = getParagraphListInfo(paragraph);
226
+ if (listInfo) {
227
+ return refusal(LIST_FORMAT_TARGET_IS_LIST,
228
+ `Paragraph ${paragraphIndex ?? ''} is already a list item (numId ${listInfo.numId}, level ${listInfo.ilvl}); convert-plain converts plain paragraphs only.`.replace(' ', ' '),
229
+ { paragraphIndex, numId: listInfo.numId, level: listInfo.ilvl, targetExcerpt: excerpt(text) },
230
+ { action: 'use_paragraph_numbering', hint: 'Narrow the range to plain paragraphs. To move a list item to another existing list or level, use paragraph-format with properties.numbering.' });
231
+ }
232
+ const current = getCurrentParagraphProperties(paragraph);
233
+ const suppressed = current.numPr?.numId === '0';
234
+ const styled = suppressed ? null : styleNumbering(stylesDoc, current.pStyle);
235
+ if (styled) {
236
+ return refusal(LIST_FORMAT_STYLE_NUMBERED,
237
+ `Paragraph ${paragraphIndex ?? ''} is numbered by its style "${styled.styleId}" (numId ${styled.numId}); it is already a list item through the style.`.replace(' ', ' '),
238
+ { paragraphIndex, styleId: styled.styleId, numId: styled.numId, targetExcerpt: excerpt(text) },
239
+ { action: 'convert_in_host', hint: 'Style-linked numbering is not rebound by list-format-v1. Change the paragraph style or the style\'s numbering in the host.' });
240
+ }
241
+ if (!text.trim()) {
242
+ skipped.push({ paragraph, paragraphIndex, reason: 'empty' });
243
+ continue;
244
+ }
245
+ if (strip === 'none') {
246
+ entries.push({ paragraph, paragraphIndex, text, parsed: null });
247
+ continue;
248
+ }
249
+ const parsed = parseTypedListMarker(text);
250
+ if (parsed?.unsupported) {
251
+ const detail = {
252
+ multilevel: 'multi-level markers such as "2.1." need a multi-level list definition',
253
+ parenthesized: 'parenthesized markers such as "(a)" are not supported',
254
+ zero: 'a marker value of 0 cannot start a Word list'
255
+ }[parsed.unsupported];
256
+ return refusal(LIST_MARKER_UNSUPPORTED,
257
+ `The typed marker "${parsed.marker}" is not supported by list-format-v1: ${detail}.`,
258
+ { reason: parsed.unsupported, marker: parsed.marker, paragraphIndex, targetExcerpt: excerpt(text) },
259
+ { action: 'convert_in_host', hint: 'Convert this paragraph in the host, or leave the marker as literal text.' });
260
+ }
261
+ if (!parsed) {
262
+ // A non-list paragraph between converted headers stays body text; the list continues across it.
263
+ skipped.push({ paragraph, paragraphIndex, reason: 'no-marker' });
264
+ continue;
265
+ }
266
+ entries.push({ paragraph, paragraphIndex, text, parsed });
267
+ }
268
+
269
+ if (entries.length === 0) {
270
+ return refusal(LIST_MARKER_NOT_FOUND,
271
+ strip === 'marker'
272
+ ? 'No target paragraph starts with a typed list marker (for example "A. ", "1. ", "iv. " or "- "), so there is nothing to strip.'
273
+ : 'The target range has no non-empty paragraph to convert.',
274
+ { targetExcerpt: excerpt(skipped[0] ? extractCanonicalParagraphText(skipped[0].paragraph) : '') },
275
+ { action: 'change_request', hint: 'Target the paragraphs that carry the typed markers, or pass strip: "none" with list.format to bind paragraphs whose text has no marker.', field: 'list.strip', allowedValue: 'none' });
276
+ }
277
+
278
+ // 2. One format for the whole run.
279
+ let format = request.format;
280
+ if (strip === 'marker') {
281
+ const resolved = resolveMarkerFormat(entries.map(entry => entry.parsed), request.format);
282
+ if (resolved.error) {
283
+ const { reason } = resolved.error;
284
+ if (reason === 'ambiguous') {
285
+ return refusal(LIST_MARKER_AMBIGUOUS,
286
+ `The typed marker "${resolved.error.marker}" reads as ${resolved.error.formats.join(' or ')}; list-format will not guess.`,
287
+ { marker: resolved.error.marker, formats: resolved.error.formats },
288
+ { action: 'set_list_format', hint: `Pass list.format (${resolved.error.formats.join(' or ')}).`, field: 'list.format', allowedValues: resolved.error.formats });
289
+ }
290
+ const message = reason === 'format-mismatch'
291
+ ? `The typed marker "${resolved.error.marker}" is not a ${resolved.error.requestedFormat} marker.`
292
+ : `The target range mixes list markers (${[...new Set(resolved.error.markers)].join(', ')}); one convert-plain operation converts one list format.`;
293
+ return refusal(LIST_MARKER_UNSUPPORTED, message,
294
+ { reason, ...(resolved.error.marker ? { marker: resolved.error.marker } : {}), ...(resolved.error.markers ? { markers: resolved.error.markers } : {}) },
295
+ { action: 'change_request', hint: reason === 'format-mismatch' ? 'Omit list.format to use the typed marker, or correct it.' : 'Split the range into one operation per marker format.' });
296
+ }
297
+ format = resolved.format;
298
+ }
299
+ if (request.kind && (request.kind === 'bullet') !== (format === 'bullet')) {
300
+ return refusal(LIST_MARKER_UNSUPPORTED, `The typed markers are ${format} markers, not a ${request.kind} list.`,
301
+ { reason: 'format-mismatch', requestedKind: request.kind },
302
+ { action: 'change_request', hint: 'Omit list.kind, or target paragraphs whose markers match it.' });
303
+ }
304
+ const terminator = strip === 'marker' ? entries[0].parsed.terminator : '.';
305
+ const bulletChar = strip === 'marker' ? (entries[0].parsed.bulletChar || null) : null;
306
+ const level = request.level || 0;
307
+
308
+ // 3. Paragraph-property snapshots and marker runs.
309
+ for (const entry of entries) {
310
+ const pendingChange = getCurrentChild(getDirectWordChild(entry.paragraph, 'pPr'), 'pPrChange');
311
+ const pendingAuthor = pendingChange?.getAttribute('w:author') ?? pendingChange?.getAttribute('author') ?? '';
312
+ if (generateRedlines && pendingChange && pendingAuthor !== author) {
313
+ return refusal(PARAGRAPH_FORMAT_REVISION_CONFLICT,
314
+ `Paragraph ${entry.paragraphIndex ?? ''} already has a pending paragraph-property change by "${pendingAuthor || 'unattributed'}". Overwriting its snapshot would change what Reject All restores.`.replace(' ', ' '),
315
+ { pendingAuthor, paragraphIndex: entry.paragraphIndex },
316
+ { action: 'resolve_pending_revision', hint: 'Ask the user to accept or reject the existing paragraph formatting change first; this tool does not resolve revisions by other reviewers.' });
317
+ }
318
+ entry.keepSnapshot = !!pendingChange;
319
+ if (strip === 'marker') {
320
+ const located = locateMarkerRuns(entry.paragraph, entry.parsed.prefixLength, entry.text.slice(0, entry.parsed.prefixLength));
321
+ if (located.reason) {
322
+ return refusal(LIST_MARKER_UNSUPPORTED,
323
+ `The typed marker "${entry.parsed.marker}" is not plain run text (${located.reason}), so it cannot be stripped as one tracked deletion.`,
324
+ { reason: 'marker-shape', shape: located.reason, marker: entry.parsed.marker, paragraphIndex: entry.paragraphIndex, targetExcerpt: excerpt(entry.text) },
325
+ { action: 'convert_in_host', hint: 'The marker sits inside a hyperlink, field, content control or pending revision. Resolve that markup first or convert the paragraph in the host.' });
326
+ }
327
+ entry.segments = located.segments;
328
+ }
329
+ }
330
+
331
+ // 4. Values and list groups. A value that does not continue the previous one starts a new list, so every
332
+ // label Word renders after Accept All is the label the paragraph showed as text.
333
+ const sequence = sequenceMapOf(context.runtimeContext);
334
+ const sequenceKey = listFormatSequenceKey({ format, level, terminator, bulletChar });
335
+ const previous = sequence?.get(sequenceKey) || null;
336
+ const previousLive = previous && numIdIsLive(xmlDoc, previous.numId);
337
+ let nextValue = strip === 'none'
338
+ ? (request.start ?? (previousLive && Number.isInteger(previous.nextStartAt) ? previous.nextStartAt : 1))
339
+ : null;
340
+ for (const entry of entries) {
341
+ if (format === 'bullet') entry.value = null;
342
+ else if (strip === 'marker') entry.value = markerValue(entry.parsed, format);
343
+ else entry.value = nextValue++;
344
+ }
345
+
346
+ const numberingIdState = numberingIdStateOf(context.runtimeContext, context);
347
+ const spec = listLevelSpec({ format, level, terminator, bulletChar });
348
+ const parts = [];
349
+ const allocatedNumIds = [];
350
+ let groupNumId = null;
351
+ let lastValue = null;
352
+ for (const [index, entry] of entries.entries()) {
353
+ const continues = index > 0 && (format === 'bullet' || entry.value === lastValue + 1);
354
+ if (!continues) {
355
+ const reusePrevious = index === 0 && previousLive
356
+ && (format === 'bullet' || previous.nextStartAt === entry.value);
357
+ if (reusePrevious) {
358
+ groupNumId = String(previous.numId);
359
+ } else {
360
+ const allocated = allocateNumberingDefinition({
361
+ numberingIdState,
362
+ levels: [spec],
363
+ startOverrides: entry.value != null && entry.value !== 1 ? { [level]: entry.value } : undefined
364
+ });
365
+ if (!allocated) {
366
+ return refusal('NUMBERING_ID_EXHAUSTED', 'No numbering ids are available for a new list definition.', {},
367
+ { action: 'report_library_failure' });
368
+ }
369
+ groupNumId = String(allocated.numId);
370
+ allocatedNumIds.push(allocated.numId);
371
+ parts.push(allocated.numberingXml);
372
+ }
373
+ }
374
+ entry.numId = groupNumId;
375
+ lastValue = entry.value;
376
+ }
377
+
378
+ // 5. Mutate.
379
+ for (const entry of entries) {
380
+ let pPr = getDirectWordChild(entry.paragraph, 'pPr');
381
+ if (!pPr) {
382
+ pPr = createWordElement(xmlDoc, 'w:pPr');
383
+ entry.paragraph.insertBefore(pPr, entry.paragraph.firstChild);
384
+ }
385
+ // A same-author pending snapshot already records the earliest "before" state; keep it.
386
+ if (generateRedlines && !entry.keepSnapshot) snapshotAndAttachPPrChange(xmlDoc, pPr, author);
387
+ applyParagraphPropertiesToPPr(xmlDoc, pPr, { numbering: { numId: entry.numId, level } });
388
+ if (strip === 'marker') {
389
+ stripMarkerPrefix(xmlDoc, entry.segments, entry.parsed.prefixLength, {
390
+ author,
391
+ generateRedlines,
392
+ revisionIdAllocator: context.revisionIdAllocator || null
393
+ });
394
+ }
395
+ }
396
+
397
+ const last = entries[entries.length - 1];
398
+ sequence?.set(sequenceKey, { numId: last.numId, nextStartAt: last.value != null ? last.value + 1 : null });
399
+
400
+ return {
401
+ ok: true,
402
+ format,
403
+ level,
404
+ strip,
405
+ items: entries.map(entry => ({
406
+ paragraph: entry.paragraph,
407
+ paragraphIndex: entry.paragraphIndex,
408
+ marker: entry.parsed?.marker ?? null,
409
+ value: entry.value,
410
+ numId: entry.numId
411
+ })),
412
+ skipped,
413
+ numberingXml: parts.length > 0 ? mergeNumberingParts('', parts) : null,
414
+ allocatedNumIds
415
+ };
416
+ }