@ansonlai/docx-redline-js 0.8.4 → 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.
- package/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +176 -0
- package/README.md +125 -12
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4189 -767
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4292 -870
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4292 -870
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +54 -1
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +736 -749
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +47 -1
- package/index.js +11 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -975
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +213 -0
- package/services/document-operation-contract.js +75 -0
- package/services/document-operation-mutations.js +3346 -2721
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM core of `list-format` `change-format` and `join` (canonical list operations plan, WP-5).
|
|
3
|
+
*
|
|
4
|
+
* Both modes rebind EXISTING list items; neither edits a shared numbering definition in place (numbering.xml is not
|
|
5
|
+
* revision-tracked, so an in-place edit would be invisible to Reject All and would restyle every other list that uses
|
|
6
|
+
* the definition):
|
|
7
|
+
* - `change-format` allocates a NEW `w:abstractNum` + `w:num` (copying the source definition's levels and geometry,
|
|
8
|
+
* with only the requested level redefined) and rebinds the paragraphs to it with one tracked `w:pPrChange` each.
|
|
9
|
+
* `scope: 'list'` rebinds every live paragraph of the source `numId`; `scope: 'range'` rebinds only the target range,
|
|
10
|
+
* which must run to the end of the list, with per-level start values so the count continues.
|
|
11
|
+
* - `join` rebinds the paragraphs of one list to the `numId` of an earlier list (`continueFrom`), so they continue
|
|
12
|
+
* its count. Nothing is allocated.
|
|
13
|
+
*
|
|
14
|
+
* Reject All restores each paragraph's previous `w:numPr` from the `w:pPrChange` snapshot, so the original labels
|
|
15
|
+
* return; Accept All keeps the new binding. Tracked-deleted paragraphs never change `numPr` (allocator rule R1).
|
|
16
|
+
*
|
|
17
|
+
* Everything is refused in a planning pass before any mutation when the result is not exactly predictable from the
|
|
18
|
+
* Word numbering model (verified with Word: `w:num` instances sharing one `w:abstractNum` continue each other's count,
|
|
19
|
+
* a level start (`w:start` or `w:startOverride`) applies again on every restart of that level, and an item below an unused level
|
|
20
|
+
* implicitly starts that level). Current properties are read only through `core/current-properties.js`; no
|
|
21
|
+
* paragraph-mark revision is created (a rebinding is a property change).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
25
|
+
import {
|
|
26
|
+
getCurrentChild,
|
|
27
|
+
getCurrentParagraphProperties,
|
|
28
|
+
getDirectWordChild,
|
|
29
|
+
getDirectWordChildren
|
|
30
|
+
} from '../core/current-properties.js';
|
|
31
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
32
|
+
import { getParagraphListInfo } from '../core/list-targeting.js';
|
|
33
|
+
import { createWordElement } from '../core/word-xml.js';
|
|
34
|
+
import { snapshotAndAttachPPrChange } from '../engine/run-builders.js';
|
|
35
|
+
import { applyParagraphPropertiesToPPr } from '../engine/rpr-helpers.js';
|
|
36
|
+
import { allocateNumberingDefinition, mergeNumberingParts } from './numbering-allocation.js';
|
|
37
|
+
import { isTrackedDeletedParagraph } from './numbering-helpers.js';
|
|
38
|
+
import {
|
|
39
|
+
LIST_FORMAT_DEFINITION_UNSUPPORTED,
|
|
40
|
+
LIST_FORMAT_JOIN_INVALID,
|
|
41
|
+
LIST_FORMAT_MIXED_RANGE,
|
|
42
|
+
LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
43
|
+
LIST_FORMAT_STYLE_LINKED,
|
|
44
|
+
LIST_FORMAT_STYLE_NUMBERED,
|
|
45
|
+
LIST_FORMAT_TARGET_NOT_LIST,
|
|
46
|
+
validateLevelText
|
|
47
|
+
} from './list-format.js';
|
|
48
|
+
import { numberingIdStateOf, parseStyles, refusal, styleNumbering } from './list-format-mutation.js';
|
|
49
|
+
|
|
50
|
+
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
51
|
+
const PARAGRAPH_FORMAT_REVISION_CONFLICT = 'PARAGRAPH_FORMAT_REVISION_CONFLICT';
|
|
52
|
+
const MAX_LEVEL = 8;
|
|
53
|
+
|
|
54
|
+
const excerpt = (text, limit = 80) => {
|
|
55
|
+
const value = String(text ?? '');
|
|
56
|
+
return value.length > limit ? `${value.slice(0, limit)}...` : value;
|
|
57
|
+
};
|
|
58
|
+
const readVal = element => element?.getAttribute?.('w:val') ?? element?.getAttribute?.('val') ?? null;
|
|
59
|
+
const readInt = (element, name = 'val') => {
|
|
60
|
+
const raw = element?.getAttribute?.(`w:${name}`) ?? element?.getAttribute?.(name);
|
|
61
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10);
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
63
|
+
};
|
|
64
|
+
const isBulletFormat = numFmt => numFmt === 'bullet';
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
67
|
+
// Numbering model
|
|
68
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parsed numbering definitions: `nums` (numId -> { element, abstractId, startOverrides, levelOverrides }) and
|
|
72
|
+
* `abstracts` (abstractNumId -> element). Ids are strings.
|
|
73
|
+
*/
|
|
74
|
+
function readNumbering(numberingXml) {
|
|
75
|
+
const text = String(numberingXml || '');
|
|
76
|
+
const doc = text.trim() ? parseOoxmlSafe(text, 'application/xml')?.doc : null;
|
|
77
|
+
const nums = new Map();
|
|
78
|
+
const abstracts = new Map();
|
|
79
|
+
if (!doc) return { nums, abstracts };
|
|
80
|
+
for (const abstract of Array.from(doc.getElementsByTagNameNS('*', 'abstractNum'))) {
|
|
81
|
+
const id = readInt(abstract, 'abstractNumId');
|
|
82
|
+
if (id != null && !abstracts.has(String(id))) abstracts.set(String(id), abstract);
|
|
83
|
+
}
|
|
84
|
+
for (const num of Array.from(doc.getElementsByTagNameNS('*', 'num'))) {
|
|
85
|
+
const id = readInt(num, 'numId');
|
|
86
|
+
if (id == null || nums.has(String(id))) continue;
|
|
87
|
+
const startOverrides = new Map();
|
|
88
|
+
const levelOverrides = new Set();
|
|
89
|
+
for (const override of getDirectWordChildren(num, 'lvlOverride')) {
|
|
90
|
+
const ilvl = readInt(override, 'ilvl') ?? 0;
|
|
91
|
+
const start = readInt(getDirectWordChild(override, 'startOverride'));
|
|
92
|
+
if (start != null) startOverrides.set(ilvl, start);
|
|
93
|
+
if (getDirectWordChild(override, 'lvl')) levelOverrides.add(ilvl);
|
|
94
|
+
}
|
|
95
|
+
nums.set(String(id), {
|
|
96
|
+
element: num,
|
|
97
|
+
abstractId: String(readInt(getDirectWordChild(num, 'abstractNumId')) ?? ''),
|
|
98
|
+
startOverrides,
|
|
99
|
+
levelOverrides
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { nums, abstracts };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The resolved definition of one `numId`, or a refusal reason. */
|
|
106
|
+
function resolveDefinition(numbering, numId) {
|
|
107
|
+
const num = numbering.nums.get(String(numId));
|
|
108
|
+
if (!num) return { reason: 'missing' };
|
|
109
|
+
const abstract = numbering.abstracts.get(num.abstractId);
|
|
110
|
+
if (!abstract) return { reason: 'missing' };
|
|
111
|
+
const levels = new Map();
|
|
112
|
+
for (const lvl of getDirectWordChildren(abstract, 'lvl')) {
|
|
113
|
+
const ilvl = readInt(lvl, 'ilvl') ?? 0;
|
|
114
|
+
levels.set(ilvl, {
|
|
115
|
+
numFmt: readVal(getDirectWordChild(lvl, 'numFmt')) || 'decimal',
|
|
116
|
+
lvlText: readVal(getDirectWordChild(lvl, 'lvlText')) ?? '',
|
|
117
|
+
start: readInt(getDirectWordChild(lvl, 'start')) ?? 0,
|
|
118
|
+
restart: !!getDirectWordChild(lvl, 'lvlRestart'),
|
|
119
|
+
pStyle: readVal(getDirectWordChild(lvl, 'pStyle'))
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const styleLink = readVal(getDirectWordChild(abstract, 'numStyleLink')) || readVal(getDirectWordChild(abstract, 'styleLink'));
|
|
123
|
+
const levelStyles = [...levels.values()].map(level => level.pStyle).filter(Boolean);
|
|
124
|
+
return {
|
|
125
|
+
numId: String(numId),
|
|
126
|
+
num,
|
|
127
|
+
abstract,
|
|
128
|
+
abstractId: num.abstractId,
|
|
129
|
+
levels,
|
|
130
|
+
styleLink: styleLink || null,
|
|
131
|
+
levelStyles,
|
|
132
|
+
/** Effective start of a level for this instance: the num's startOverride, else the abstract's start. */
|
|
133
|
+
startOf: ilvl => (num.startOverrides.has(ilvl) ? num.startOverrides.get(ilvl) : (levels.get(ilvl)?.start ?? 0))
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Paragraph styles in styles.xml that bind one of `numIds` through their own `w:numPr`. */
|
|
138
|
+
function stylesBindingNumIds(stylesDoc, numIds) {
|
|
139
|
+
if (!stylesDoc) return [];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const style of Array.from(stylesDoc.getElementsByTagNameNS('*', 'style'))) {
|
|
142
|
+
const numId = readVal(getCurrentChild(getCurrentChild(getDirectWordChild(style, 'pPr'), 'numPr'), 'numId'));
|
|
143
|
+
if (numId != null && numIds.has(String(numId))) out.push(style.getAttribute('w:styleId') ?? style.getAttribute('styleId'));
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Word list paragraphs: body paragraphs whose CURRENT `numPr` binds `numId`, in document order. */
|
|
149
|
+
function listMembers(xmlDoc, numId) {
|
|
150
|
+
const members = [];
|
|
151
|
+
for (const paragraph of Array.from(xmlDoc.getElementsByTagNameNS(W_NS, 'p'))) {
|
|
152
|
+
const info = getParagraphListInfo(paragraph);
|
|
153
|
+
if (!info || info.numId !== String(numId)) continue;
|
|
154
|
+
members.push({
|
|
155
|
+
paragraph,
|
|
156
|
+
ilvl: info.ilvl,
|
|
157
|
+
deleted: isTrackedDeletedParagraph(paragraph),
|
|
158
|
+
inTextbox: hasAncestor(paragraph, 'txbxContent')
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return members;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasAncestor(node, localName) {
|
|
165
|
+
for (let current = node?.parentNode; current && current.nodeType === 1; current = current.parentNode) {
|
|
166
|
+
if (current.localName === localName) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** numIds (other than `numId`) that share its abstractNum and bind at least one live body paragraph. */
|
|
172
|
+
function sharedDefinitionUsers(xmlDoc, numbering, definition) {
|
|
173
|
+
const sharing = [...numbering.nums.entries()]
|
|
174
|
+
.filter(([id, num]) => id !== definition.numId && num.abstractId === definition.abstractId)
|
|
175
|
+
.map(([id]) => id);
|
|
176
|
+
return sharing.filter(id => listMembers(xmlDoc, id).some(member => !member.deleted));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Word's level counters over list items in document order. `startOf(ilvl)` is the instance start; an item at level k
|
|
181
|
+
* implicitly starts every unused level above it, increments level k and restarts every deeper level. Returns, per
|
|
182
|
+
* item, the counter values of levels 0..k (what a multi-level label such as "%1.%2" can show).
|
|
183
|
+
*/
|
|
184
|
+
function simulateCounters(levels, startOf, initial = []) {
|
|
185
|
+
const counters = Array.from({ length: MAX_LEVEL + 1 }, (_, i) => (initial[i] ?? null));
|
|
186
|
+
const values = [];
|
|
187
|
+
for (const ilvl of levels) {
|
|
188
|
+
for (let j = 0; j < ilvl; j += 1) if (counters[j] == null) counters[j] = startOf(j);
|
|
189
|
+
counters[ilvl] = counters[ilvl] == null ? startOf(ilvl) : counters[ilvl] + 1;
|
|
190
|
+
for (let j = ilvl + 1; j <= MAX_LEVEL; j += 1) counters[j] = null;
|
|
191
|
+
values.push(counters.slice(0, ilvl + 1));
|
|
192
|
+
}
|
|
193
|
+
return { values, counters };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
197
|
+
// Shared planning
|
|
198
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Classifies the target range: every live paragraph must be a direct list item of one numId. Returns
|
|
202
|
+
* `{ numId, items, skipped }` or a refusal.
|
|
203
|
+
*/
|
|
204
|
+
function classifyRange(paragraphs, { stylesDoc, indexOf, mode }) {
|
|
205
|
+
const items = [];
|
|
206
|
+
const skipped = [];
|
|
207
|
+
const nonList = [];
|
|
208
|
+
for (const paragraph of paragraphs) {
|
|
209
|
+
const paragraphIndex = indexOf(paragraph);
|
|
210
|
+
if (isTrackedDeletedParagraph(paragraph)) {
|
|
211
|
+
// R1: a tracked-deleted paragraph keeps its numPr so Reject All restores it.
|
|
212
|
+
skipped.push({ paragraph, paragraphIndex, reason: 'tracked-deleted' });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const info = getParagraphListInfo(paragraph);
|
|
216
|
+
if (info) {
|
|
217
|
+
items.push({ paragraph, paragraphIndex, ilvl: info.ilvl, numId: info.numId });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const current = getCurrentParagraphProperties(paragraph);
|
|
221
|
+
const styled = current.numPr?.numId === '0' ? null : styleNumbering(stylesDoc, current.pStyle);
|
|
222
|
+
if (styled) {
|
|
223
|
+
return refusal(LIST_FORMAT_STYLE_NUMBERED,
|
|
224
|
+
`Paragraph ${paragraphIndex ?? ''} is numbered by its style "${styled.styleId}" (numId ${styled.numId}), not by a direct list binding; ${mode} rebinds direct list items only.`.replace(' ', ' '),
|
|
225
|
+
{ paragraphIndex, styleId: styled.styleId, numId: styled.numId, targetExcerpt: excerpt(extractCanonicalParagraphText(paragraph)) },
|
|
226
|
+
{ action: 'convert_in_host', hint: 'Style-linked numbering is not rebound by list-format. Change the style\'s numbering in the host.' });
|
|
227
|
+
}
|
|
228
|
+
nonList.push({ paragraph, paragraphIndex });
|
|
229
|
+
}
|
|
230
|
+
if (items.length === 0) {
|
|
231
|
+
return refusal(LIST_FORMAT_TARGET_NOT_LIST,
|
|
232
|
+
`The target range has no list item; ${mode} changes existing lists.`,
|
|
233
|
+
{ paragraphIndexes: nonList.map(item => item.paragraphIndex), targetExcerpt: excerpt(nonList[0] ? extractCanonicalParagraphText(nonList[0].paragraph) : '') },
|
|
234
|
+
{ action: 'use_list_format_convert_plain', hint: 'To turn plain paragraphs into a list use list-format convert-plain (typed markers) or paragraph-format numbering (an existing list).' });
|
|
235
|
+
}
|
|
236
|
+
if (nonList.length > 0) {
|
|
237
|
+
return refusal(LIST_FORMAT_MIXED_RANGE,
|
|
238
|
+
`The target range mixes list items and paragraphs that are not list items (paragraphs ${nonList.map(item => item.paragraphIndex).join(', ')}).`,
|
|
239
|
+
{ reason: 'non-list', paragraphIndexes: nonList.map(item => item.paragraphIndex) },
|
|
240
|
+
{ action: 'narrow_range', hint: 'Target only list items: one paragraph of the list is enough with scope "list".' });
|
|
241
|
+
}
|
|
242
|
+
const numIds = [...new Set(items.map(item => item.numId))];
|
|
243
|
+
if (numIds.length > 1) {
|
|
244
|
+
return refusal(LIST_FORMAT_MIXED_RANGE,
|
|
245
|
+
`The target range spans ${numIds.length} lists (numIds ${numIds.join(', ')}); one ${mode} operation changes one list.`,
|
|
246
|
+
{ reason: 'several-lists', numIds, paragraphIndexes: items.filter(item => item.numId !== items[0].numId).map(item => item.paragraphIndex) },
|
|
247
|
+
{ action: 'narrow_range', hint: 'Send one operation per list.' });
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, numId: numIds[0], items, skipped };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Refusals that depend on the source definition: missing, level overrides, style links, shared abstracts. */
|
|
253
|
+
function checkDefinition(xmlDoc, numbering, numId, { stylesDoc, role }) {
|
|
254
|
+
const definition = resolveDefinition(numbering, numId);
|
|
255
|
+
if (definition.reason === 'missing') {
|
|
256
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED,
|
|
257
|
+
`numId ${numId} (${role}) has no numbering definition in the numbering part, so its levels cannot be read.`,
|
|
258
|
+
{ reason: 'missing', numId: String(numId) },
|
|
259
|
+
{ action: 'inspect_numbering', hint: 'Bare document.xml callers must pass the package\'s word/numbering.xml as numberingXml.' });
|
|
260
|
+
}
|
|
261
|
+
if (definition.num.levelOverrides.size > 0) {
|
|
262
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED,
|
|
263
|
+
`numId ${numId} (${role}) redefines level ${[...definition.num.levelOverrides].join(', ')} inside its w:num (w:lvlOverride/w:lvl); list-format does not rebind such instance-level definitions.`,
|
|
264
|
+
{ reason: 'level-override', numId: String(numId), levels: [...definition.num.levelOverrides] },
|
|
265
|
+
{ action: 'convert_in_host', hint: 'Change this list in the host.' });
|
|
266
|
+
}
|
|
267
|
+
const stylesUsingList = stylesBindingNumIds(stylesDoc, new Set([String(numId), ...[...numbering.nums.entries()]
|
|
268
|
+
.filter(([, num]) => num.abstractId === definition.abstractId).map(([id]) => id)]));
|
|
269
|
+
if (definition.styleLink || definition.levelStyles.length > 0 || stylesUsingList.length > 0) {
|
|
270
|
+
const styleIds = [...new Set([definition.styleLink, ...definition.levelStyles, ...stylesUsingList].filter(Boolean))];
|
|
271
|
+
return refusal(LIST_FORMAT_STYLE_LINKED,
|
|
272
|
+
`numId ${numId} (${role}) is style-linked (${styleIds.map(id => `"${id}"`).join(', ')}): paragraphs can join it through a style without a direct binding, so rebinding the direct items would split the list unpredictably.`,
|
|
273
|
+
{ numId: String(numId), styleIds },
|
|
274
|
+
{ action: 'convert_in_host', hint: 'Style-linked lists (heading numbering, list styles) are not changed by list-format. Change the numbering of the style in the host.' });
|
|
275
|
+
}
|
|
276
|
+
const shared = sharedDefinitionUsers(xmlDoc, numbering, definition);
|
|
277
|
+
if (shared.length > 0) {
|
|
278
|
+
return refusal(LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
279
|
+
`numId ${numId} (${role}) shares its abstract definition ${definition.abstractId} with numIds ${shared.join(', ')}, which Word numbers as one continuous list; rebinding one of them would renumber the others.`,
|
|
280
|
+
{ reason: 'shared-definition', numId: String(numId), sharedNumIds: shared },
|
|
281
|
+
{ action: 'convert_in_host', hint: 'Change these lists together in the host, or restart the other list first.' });
|
|
282
|
+
}
|
|
283
|
+
if (definition.num.element && [...definition.levels.keys()].length === 0) {
|
|
284
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED, `numId ${numId} (${role}) has no levels.`,
|
|
285
|
+
{ reason: 'undefined-level', numId: String(numId) }, { action: 'convert_in_host' });
|
|
286
|
+
}
|
|
287
|
+
return { ok: true, definition };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The paragraphs a scope rebinds. `list`: every live member of the source list. `range`: the range items, which must
|
|
292
|
+
* run to the end of the list (otherwise the following items would renumber). Returns `{ rebind, preceding }`.
|
|
293
|
+
*/
|
|
294
|
+
function planScope(xmlDoc, numId, rangeItems, scope, indexOf, mode) {
|
|
295
|
+
const members = listMembers(xmlDoc, numId);
|
|
296
|
+
const live = members.filter(member => !member.deleted);
|
|
297
|
+
const textbox = live.find(member => member.inTextbox);
|
|
298
|
+
if (textbox) {
|
|
299
|
+
return refusal(LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
300
|
+
`numId ${numId} has a list item inside a text box (paragraph ${indexOf(textbox.paragraph) ?? '?'}); list-format changes body lists only.`,
|
|
301
|
+
{ reason: 'textbox-member', numId: String(numId) },
|
|
302
|
+
{ action: 'convert_in_host', hint: 'Change this list in the host.' });
|
|
303
|
+
}
|
|
304
|
+
const inRange = new Set(rangeItems.map(item => item.paragraph));
|
|
305
|
+
if (scope === 'list') {
|
|
306
|
+
return { ok: true, rebind: live.map(member => ({ ...member, paragraphIndex: indexOf(member.paragraph), inTarget: inRange.has(member.paragraph) })), preceding: [] };
|
|
307
|
+
}
|
|
308
|
+
const firstIndex = live.findIndex(member => inRange.has(member.paragraph));
|
|
309
|
+
const following = live.slice(firstIndex).filter(member => !inRange.has(member.paragraph));
|
|
310
|
+
if (following.length > 0) {
|
|
311
|
+
const lastIndex = indexOf(live[live.length - 1].paragraph);
|
|
312
|
+
return refusal(LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
313
|
+
`scope "range" would split numId ${numId} mid-sequence: ${following.length} later item(s) of the list (paragraphs ${following.map(member => indexOf(member.paragraph)).join(', ')}) would stay on the source list and renumber.`,
|
|
314
|
+
{ reason: 'mid-sequence', numId: String(numId), followingParagraphIndexes: following.map(member => indexOf(member.paragraph)), lastListParagraphIndex: lastIndex },
|
|
315
|
+
{ action: 'extend_range_or_use_list_scope', hint: `Extend targetEnd to the last item of the list (paragraph ${lastIndex}), or use scope "list" to ${mode === 'join' ? 'join' : 'change'} the whole list.` });
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
rebind: live.slice(firstIndex).map(member => ({ ...member, paragraphIndex: indexOf(member.paragraph), inTarget: true })),
|
|
320
|
+
preceding: live.slice(0, firstIndex)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function checkPendingSnapshots(rebind, { author, generateRedlines }) {
|
|
325
|
+
// An untracked edit has no Reject All to fall back on, so it must stay inside the paragraphs it names.
|
|
326
|
+
const outside = rebind.filter(item => !item.inTarget);
|
|
327
|
+
if (!generateRedlines && outside.length > 0) {
|
|
328
|
+
return refusal('INVALID_OPERATION',
|
|
329
|
+
`An untracked (generateRedlines: false) list-format would rebind ${outside.length} paragraph(s) outside the target (paragraphs ${outside.map(item => item.paragraphIndex).join(', ')}); untracked edits stay inside their target.`,
|
|
330
|
+
{ field: 'generateRedlines', paragraphIndexes: outside.map(item => item.paragraphIndex) },
|
|
331
|
+
{ action: 'change_request', hint: 'Keep tracking on, or target the whole list with target..targetEnd.' });
|
|
332
|
+
}
|
|
333
|
+
for (const item of rebind) {
|
|
334
|
+
const pendingChange = getCurrentChild(getDirectWordChild(item.paragraph, 'pPr'), 'pPrChange');
|
|
335
|
+
const pendingAuthor = pendingChange?.getAttribute('w:author') ?? pendingChange?.getAttribute('author') ?? '';
|
|
336
|
+
if (generateRedlines && pendingChange && pendingAuthor !== author) {
|
|
337
|
+
return refusal(PARAGRAPH_FORMAT_REVISION_CONFLICT,
|
|
338
|
+
`Paragraph ${item.paragraphIndex ?? ''} of the list already has a pending paragraph-property change by "${pendingAuthor || 'unattributed'}". Overwriting its snapshot would change what Reject All restores.`.replace(' ', ' '),
|
|
339
|
+
{ pendingAuthor, paragraphIndex: item.paragraphIndex },
|
|
340
|
+
{ 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.' });
|
|
341
|
+
}
|
|
342
|
+
item.keepSnapshot = !!pendingChange;
|
|
343
|
+
}
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function rebindParagraphs(xmlDoc, rebind, numId, { author, generateRedlines }) {
|
|
348
|
+
for (const item of rebind) {
|
|
349
|
+
let pPr = getDirectWordChild(item.paragraph, 'pPr');
|
|
350
|
+
if (!pPr) {
|
|
351
|
+
pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
352
|
+
item.paragraph.insertBefore(pPr, item.paragraph.firstChild);
|
|
353
|
+
}
|
|
354
|
+
// A same-author pending snapshot already records the earliest "before" state; keep it.
|
|
355
|
+
if (generateRedlines && !item.keepSnapshot) snapshotAndAttachPPrChange(xmlDoc, pPr, author);
|
|
356
|
+
applyParagraphPropertiesToPPr(xmlDoc, pPr, { numbering: { numId, level: item.ilvl } });
|
|
357
|
+
item.numId = String(numId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const unchanged = (message, extra = {}) => ({ ok: true, unchanged: true, message, ...extra });
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
364
|
+
// change-format
|
|
365
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Changes the numbering format of the list the target range belongs to.
|
|
369
|
+
*
|
|
370
|
+
* @param {Document} xmlDoc
|
|
371
|
+
* @param {Element[]} paragraphs - resolved target range, in document order
|
|
372
|
+
* @param {{ format: string|null, kind: string|null, lvlText: string|null, start: number|null, explicitLevel: number|null, scope: 'list'|'range' }} request
|
|
373
|
+
* @param {{
|
|
374
|
+
* author: string, generateRedlines?: boolean, runtimeContext?: object|null, stylesXml?: string|null,
|
|
375
|
+
* numberingXml?: string|null, paragraphIndexOf?: (paragraph: Element) => number|null
|
|
376
|
+
* }} context - `numberingXml` is the effective numbering: the source part plus definitions this batch allocated
|
|
377
|
+
*/
|
|
378
|
+
export function changeListParagraphsFormat(xmlDoc, paragraphs, request, context = {}) {
|
|
379
|
+
const generateRedlines = context.generateRedlines !== false;
|
|
380
|
+
const author = context.author;
|
|
381
|
+
const indexOf = typeof context.paragraphIndexOf === 'function' ? context.paragraphIndexOf : () => null;
|
|
382
|
+
const stylesDoc = parseStyles(context.stylesXml);
|
|
383
|
+
const numbering = readNumbering(context.numberingXml);
|
|
384
|
+
const scope = request.scope === 'range' ? 'range' : 'list';
|
|
385
|
+
|
|
386
|
+
const range = classifyRange(paragraphs, { stylesDoc, indexOf, mode: 'change-format' });
|
|
387
|
+
if (!range.ok) return range;
|
|
388
|
+
const checked = checkDefinition(xmlDoc, numbering, range.numId, { stylesDoc, role: 'the target list' });
|
|
389
|
+
if (!checked.ok) return checked;
|
|
390
|
+
const { definition } = checked;
|
|
391
|
+
|
|
392
|
+
// Level: explicit, else the single level the target paragraphs use.
|
|
393
|
+
const targetLevels = [...new Set(range.items.map(item => item.ilvl))].sort((a, b) => a - b);
|
|
394
|
+
if (request.explicitLevel == null && targetLevels.length > 1) {
|
|
395
|
+
return refusal(LIST_FORMAT_MIXED_RANGE,
|
|
396
|
+
`The target paragraphs use list levels ${targetLevels.join(', ')}; change-format redefines one level per operation.`,
|
|
397
|
+
{ reason: 'levels', levels: targetLevels },
|
|
398
|
+
{ action: 'set_list_level', hint: 'Pass list.level, or target paragraphs of one level.', field: 'list.level', allowedValues: targetLevels });
|
|
399
|
+
}
|
|
400
|
+
const level = request.explicitLevel ?? targetLevels[0];
|
|
401
|
+
const source = definition.levels.get(level);
|
|
402
|
+
if (!source) {
|
|
403
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED, `numId ${range.numId} does not define list level ${level}.`,
|
|
404
|
+
{ reason: 'undefined-level', numId: range.numId, level }, { action: 'set_list_level', field: 'list.level', allowedValues: [...definition.levels.keys()] });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// New level definition.
|
|
408
|
+
const numFmt = request.format ?? (request.kind === 'bullet' ? 'bullet' : source.numFmt);
|
|
409
|
+
const kindChange = isBulletFormat(source.numFmt) !== isBulletFormat(numFmt);
|
|
410
|
+
const lvlText = request.lvlText ?? (!kindChange ? source.lvlText : (isBulletFormat(numFmt) ? '•' : `%${level + 1}.`));
|
|
411
|
+
const lvlTextError = validateLevelText(lvlText, { bullet: isBulletFormat(numFmt), level });
|
|
412
|
+
if (lvlTextError) {
|
|
413
|
+
return refusal('INVALID_OPERATION', `${lvlTextError} (level ${level}, format ${numFmt})`, { field: 'list.lvlText' },
|
|
414
|
+
{ action: 'change_request', hint: isBulletFormat(numFmt) ? 'Pass the bullet character as list.lvlText.' : `Pass list.lvlText with %${level + 1}, for example "%${level + 1}."` });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const plan = planScope(xmlDoc, range.numId, range.items, scope, indexOf, 'change-format');
|
|
418
|
+
if (!plan.ok) return plan;
|
|
419
|
+
|
|
420
|
+
// Start values for the new instance.
|
|
421
|
+
const abstractStart = ilvl => definition.levels.get(ilvl)?.start ?? 0;
|
|
422
|
+
const startNew = new Map();
|
|
423
|
+
for (let ilvl = 0; ilvl <= MAX_LEVEL; ilvl += 1) startNew.set(ilvl, definition.startOf(ilvl));
|
|
424
|
+
if (scope === 'range' && plan.preceding.length > 0) {
|
|
425
|
+
const usesRestart = [...plan.preceding, ...plan.rebind].some(member => definition.levels.get(member.ilvl)?.restart);
|
|
426
|
+
if (usesRestart) {
|
|
427
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED,
|
|
428
|
+
`numId ${range.numId} uses w:lvlRestart, so the count a split would continue cannot be computed exactly.`,
|
|
429
|
+
{ reason: 'level-restart', numId: range.numId },
|
|
430
|
+
{ action: 'extend_range_or_use_list_scope', hint: 'Use scope "list".' });
|
|
431
|
+
}
|
|
432
|
+
const before = simulateCounters(plan.preceding.map(member => member.ilvl), definition.startOf);
|
|
433
|
+
const first = plan.rebind[0].ilvl;
|
|
434
|
+
for (let j = 0; j < first; j += 1) startNew.set(j, before.counters[j] ?? definition.startOf(j));
|
|
435
|
+
startNew.set(first, before.counters[first] == null ? definition.startOf(first) : before.counters[first] + 1);
|
|
436
|
+
// What Word shows for the range today (continuing the list) must equal what the new instance shows.
|
|
437
|
+
const expected = simulateCounters(plan.rebind.map(member => member.ilvl), definition.startOf, before.counters).values;
|
|
438
|
+
const actual = simulateCounters(plan.rebind.map(member => member.ilvl), ilvl => startNew.get(ilvl)).values;
|
|
439
|
+
const mismatch = actual.findIndex((values, index) => JSON.stringify(values) !== JSON.stringify(expected[index]));
|
|
440
|
+
if (mismatch >= 0) {
|
|
441
|
+
return refusal(LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
442
|
+
`scope "range" cannot continue the count of numId ${range.numId} exactly: a level whose start must change restarts inside the range (paragraph ${plan.rebind[mismatch].paragraphIndex ?? '?'}), and Word applies a level start again on every restart.`,
|
|
443
|
+
{ reason: 'nested-restart', numId: range.numId, paragraphIndex: plan.rebind[mismatch].paragraphIndex },
|
|
444
|
+
{ action: 'extend_range_or_use_list_scope', hint: 'Start the range at a top-level item of the list, or use scope "list".' });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (request.start != null) startNew.set(level, request.start);
|
|
448
|
+
|
|
449
|
+
if (!plan.rebind.some(member => member.ilvl === level)) {
|
|
450
|
+
return unchanged(`No paragraph of the ${scope === 'list' ? 'list' : 'range'} uses level ${level}; nothing would change.`, { level, sourceNumId: range.numId });
|
|
451
|
+
}
|
|
452
|
+
const startChanged = request.start != null && request.start !== definition.startOf(level);
|
|
453
|
+
if (numFmt === source.numFmt && lvlText === source.lvlText && !startChanged) {
|
|
454
|
+
return unchanged(`numId ${range.numId} level ${level} already has format ${numFmt} with level text "${lvlText}".`, { level, sourceNumId: range.numId });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const pending = checkPendingSnapshots(plan.rebind, { author, generateRedlines });
|
|
458
|
+
if (!pending.ok) return pending;
|
|
459
|
+
|
|
460
|
+
// Start values (the source instance's own w:startOverride values, a split's continuation values, list.start) are
|
|
461
|
+
// written as the w:start of the NEW abstractNum's levels. That abstractNum is used by the new instance only, so
|
|
462
|
+
// nothing shared changes. Verified in Word: after Revisions.AcceptAll() in a session, a level carrying a
|
|
463
|
+
// w:startOverride that resumes after deeper items is renumbered from the override again (labels B., B. instead of
|
|
464
|
+
// C., D.; correct only after reopening), while a level w:start renders C., D. in both.
|
|
465
|
+
const levelStarts = {};
|
|
466
|
+
for (const [ilvl, start] of startNew) {
|
|
467
|
+
if (start !== abstractStart(ilvl)) levelStarts[ilvl] = start;
|
|
468
|
+
}
|
|
469
|
+
const redefinitions = new Map(Object.entries(levelStarts).map(([ilvl, start]) => [Number(ilvl), { ilvl: Number(ilvl), start }]));
|
|
470
|
+
redefinitions.set(level, {
|
|
471
|
+
...(redefinitions.get(level) || {}),
|
|
472
|
+
ilvl: level,
|
|
473
|
+
numFmt,
|
|
474
|
+
lvlText,
|
|
475
|
+
...(numFmt !== source.numFmt ? { dropChildren: kindChange && !isBulletFormat(numFmt) ? ['isLgl', 'lvlPicBulletId', 'legacy'] : ['isLgl'] } : {}),
|
|
476
|
+
...(kindChange && !isBulletFormat(numFmt) ? { dropRunFonts: true } : {})
|
|
477
|
+
});
|
|
478
|
+
const numberingIdState = numberingIdStateOf(context.runtimeContext, context);
|
|
479
|
+
const allocated = allocateNumberingDefinition({
|
|
480
|
+
numberingIdState,
|
|
481
|
+
sourceNumberingXml: context.numberingXml,
|
|
482
|
+
sourceNumId: range.numId,
|
|
483
|
+
levels: [...redefinitions.values()]
|
|
484
|
+
});
|
|
485
|
+
if (!allocated) {
|
|
486
|
+
return refusal('NUMBERING_ID_EXHAUSTED', 'No numbering ids are available for a new list definition.', {}, { action: 'report_library_failure' });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
rebindParagraphs(xmlDoc, plan.rebind, allocated.numId, { author, generateRedlines });
|
|
490
|
+
return {
|
|
491
|
+
ok: true,
|
|
492
|
+
mode: 'change-format',
|
|
493
|
+
scope,
|
|
494
|
+
format: numFmt,
|
|
495
|
+
level,
|
|
496
|
+
lvlText,
|
|
497
|
+
sourceNumId: range.numId,
|
|
498
|
+
numId: String(allocated.numId),
|
|
499
|
+
levelStarts,
|
|
500
|
+
items: plan.rebind.map(item => ({ paragraph: item.paragraph, paragraphIndex: item.paragraphIndex, level: item.ilvl, numId: item.numId, sourceNumId: range.numId, inTarget: item.inTarget })),
|
|
501
|
+
skipped: range.skipped,
|
|
502
|
+
numberingXml: mergeNumberingParts('', [allocated.numberingXml]),
|
|
503
|
+
allocatedNumIds: [allocated.numId]
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
508
|
+
// join
|
|
509
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Binds the target list (scope `list`) or the target range (scope `range`, running to the end of its list) to the
|
|
513
|
+
* numId of `continueFromParagraph`'s list, so the items continue that list's count. Each item keeps its level.
|
|
514
|
+
*/
|
|
515
|
+
export function joinListParagraphs(xmlDoc, paragraphs, continueFromParagraph, request, context = {}) {
|
|
516
|
+
const generateRedlines = context.generateRedlines !== false;
|
|
517
|
+
const author = context.author;
|
|
518
|
+
const indexOf = typeof context.paragraphIndexOf === 'function' ? context.paragraphIndexOf : () => null;
|
|
519
|
+
const stylesDoc = parseStyles(context.stylesXml);
|
|
520
|
+
const numbering = readNumbering(context.numberingXml);
|
|
521
|
+
const scope = request.scope === 'range' ? 'range' : 'list';
|
|
522
|
+
|
|
523
|
+
const continueIndex = indexOf(continueFromParagraph);
|
|
524
|
+
const continueInfo = getParagraphListInfo(continueFromParagraph);
|
|
525
|
+
if (!continueInfo || isTrackedDeletedParagraph(continueFromParagraph)) {
|
|
526
|
+
return refusal(LIST_FORMAT_JOIN_INVALID,
|
|
527
|
+
`list.continueFrom (paragraph ${continueIndex ?? '?'}) is ${continueInfo ? 'a tracked-deleted paragraph' : 'not a list item'}; join continues an existing list.`,
|
|
528
|
+
{ reason: continueInfo ? 'deleted' : 'not-a-list', paragraphIndex: continueIndex, targetExcerpt: excerpt(extractCanonicalParagraphText(continueFromParagraph)) },
|
|
529
|
+
{ action: 'change_request', hint: 'Point list.continueFrom at a live item of the list the target should continue.', field: 'list.continueFrom' });
|
|
530
|
+
}
|
|
531
|
+
if (paragraphs.includes(continueFromParagraph)) {
|
|
532
|
+
return refusal(LIST_FORMAT_JOIN_INVALID, 'list.continueFrom is inside the target range; it must be an item of an earlier list.',
|
|
533
|
+
{ reason: 'inside-target', paragraphIndex: continueIndex }, { action: 'change_request', field: 'list.continueFrom' });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const range = classifyRange(paragraphs, { stylesDoc, indexOf, mode: 'join' });
|
|
537
|
+
if (!range.ok) return range;
|
|
538
|
+
if (range.numId === continueInfo.numId) {
|
|
539
|
+
return unchanged(`The target already belongs to the list of list.continueFrom (numId ${range.numId}).`, { sourceNumId: range.numId });
|
|
540
|
+
}
|
|
541
|
+
const checkedTarget = checkDefinition(xmlDoc, numbering, range.numId, { stylesDoc, role: 'the target list' });
|
|
542
|
+
if (!checkedTarget.ok) return checkedTarget;
|
|
543
|
+
const checkedContinue = checkDefinition(xmlDoc, numbering, continueInfo.numId, { stylesDoc, role: 'the list.continueFrom list' });
|
|
544
|
+
if (!checkedContinue.ok) return checkedContinue;
|
|
545
|
+
|
|
546
|
+
const plan = planScope(xmlDoc, range.numId, range.items, scope, indexOf, 'join');
|
|
547
|
+
if (!plan.ok) return plan;
|
|
548
|
+
|
|
549
|
+
// The continued list must end before the first joined item; otherwise its later items would renumber.
|
|
550
|
+
const all = Array.from(xmlDoc.getElementsByTagNameNS(W_NS, 'p'));
|
|
551
|
+
const firstJoined = all.indexOf(plan.rebind[0].paragraph);
|
|
552
|
+
const continued = listMembers(xmlDoc, continueInfo.numId).filter(member => !member.deleted);
|
|
553
|
+
const after = continued.filter(member => all.indexOf(member.paragraph) > firstJoined);
|
|
554
|
+
if (after.length > 0) {
|
|
555
|
+
const reason = all.indexOf(continueFromParagraph) > firstJoined ? 'after-target' : 'interleaved';
|
|
556
|
+
return refusal(reason === 'after-target' ? LIST_FORMAT_JOIN_INVALID : LIST_FORMAT_SPLIT_UNSUPPORTED,
|
|
557
|
+
reason === 'after-target'
|
|
558
|
+
? 'list.continueFrom comes after the target; join continues an earlier list.'
|
|
559
|
+
: `The list of list.continueFrom (numId ${continueInfo.numId}) has items after the joined paragraphs (paragraphs ${after.map(member => indexOf(member.paragraph)).join(', ')}); they would renumber.`,
|
|
560
|
+
{ reason, numId: continueInfo.numId, paragraphIndexes: after.map(member => indexOf(member.paragraph)) },
|
|
561
|
+
{ action: 'change_request', hint: 'Join a list to the list that immediately precedes it.', field: 'list.continueFrom' });
|
|
562
|
+
}
|
|
563
|
+
const undefinedLevel = plan.rebind.find(member => !checkedContinue.definition.levels.has(member.ilvl));
|
|
564
|
+
if (undefinedLevel) {
|
|
565
|
+
return refusal(LIST_FORMAT_DEFINITION_UNSUPPORTED,
|
|
566
|
+
`numId ${continueInfo.numId} (the list.continueFrom list) does not define level ${undefinedLevel.ilvl}, which paragraph ${undefinedLevel.paragraphIndex ?? '?'} uses.`,
|
|
567
|
+
{ reason: 'undefined-level', numId: continueInfo.numId, level: undefinedLevel.ilvl },
|
|
568
|
+
{ action: 'convert_in_host' });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const pending = checkPendingSnapshots(plan.rebind, { author, generateRedlines });
|
|
572
|
+
if (!pending.ok) return pending;
|
|
573
|
+
rebindParagraphs(xmlDoc, plan.rebind, continueInfo.numId, { author, generateRedlines });
|
|
574
|
+
return {
|
|
575
|
+
ok: true,
|
|
576
|
+
mode: 'join',
|
|
577
|
+
scope,
|
|
578
|
+
sourceNumId: range.numId,
|
|
579
|
+
numId: continueInfo.numId,
|
|
580
|
+
continueFrom: { paragraphIndex: continueIndex, numId: continueInfo.numId },
|
|
581
|
+
items: plan.rebind.map(item => ({ paragraph: item.paragraph, paragraphIndex: item.paragraphIndex, level: item.ilvl, numId: item.numId, sourceNumId: range.numId, inTarget: item.inTarget })),
|
|
582
|
+
skipped: range.skipped,
|
|
583
|
+
numberingXml: null,
|
|
584
|
+
allocatedNumIds: []
|
|
585
|
+
};
|
|
586
|
+
}
|