@ansonlai/docx-redline-js 0.8.4 → 0.10.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 +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- 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/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- 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 +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- 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 +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- 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/markdown-processor.js +5 -2
- 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/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -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 +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -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,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbering allocator: the single place that creates, remaps, continues, merges and validates
|
|
3
|
+
* numbering definitions (`w:abstractNum` / `w:num`).
|
|
4
|
+
*
|
|
5
|
+
* Requirements (docs/plans: canonical list operations WP-2, revision fidelity WP-5a):
|
|
6
|
+
* - R1: tracked-deleted source paragraphs never change `numPr` (Reject All must restore the source binding).
|
|
7
|
+
* - R2: the standalone runner and the `openDocx` facade reach numbering merge through this module only
|
|
8
|
+
* (`mergeNumberingParts` / `mergeNumberingXmlBySchemaOrder`); neither keeps a merge of its own.
|
|
9
|
+
* - R3: reuse of the source list is allocator policy (`allocateNumberingForReplacement`), not a heuristic
|
|
10
|
+
* scattered at a call site.
|
|
11
|
+
*
|
|
12
|
+
* Ids are always reserved through the document-scoped state (`createDynamicNumberingIdState`,
|
|
13
|
+
* `reserveNextNumberingIdPair`), so a newly allocated id never collides with a definition the document owns.
|
|
14
|
+
* The old `num` / `abstractNum` are never deleted or overwritten: Reject All and other paragraphs may still
|
|
15
|
+
* reference them. Current properties are read only through `core/current-properties.js`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parseOoxml, serializeOoxml } from '../engine/oxml-engine.js';
|
|
19
|
+
import { WORD_MAIN_NS } from '../core/paragraph-targeting.js';
|
|
20
|
+
import {
|
|
21
|
+
getCurrentNumIdElements,
|
|
22
|
+
getCurrentParagraphProperties,
|
|
23
|
+
getHistoricalParagraphProperties
|
|
24
|
+
} from '../core/current-properties.js';
|
|
25
|
+
import {
|
|
26
|
+
getElementId,
|
|
27
|
+
hasXmlParseError,
|
|
28
|
+
isDirectWordChild,
|
|
29
|
+
isTrackedDeletedParagraph,
|
|
30
|
+
remapNumberingPayloadForDocument,
|
|
31
|
+
reserveNextNumberingIdPair
|
|
32
|
+
} from './numbering-helpers.js';
|
|
33
|
+
|
|
34
|
+
const MAX_LEVEL = 8;
|
|
35
|
+
// CT_Lvl child order (ECMA-376 17.9.6).
|
|
36
|
+
const LVL_CHILD_ORDER = [
|
|
37
|
+
'start', 'numFmt', 'lvlRestart', 'pStyle', 'isLgl', 'suff', 'lvlText', 'lvlPicBulletId', 'legacy', 'lvlJc', 'pPr', 'rPr'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const wordChildren = (node, localName) =>
|
|
41
|
+
Array.from(node?.childNodes || []).filter(child => isDirectWordChild(child, localName));
|
|
42
|
+
const descendants = (node, localName) => Array.from(node?.getElementsByTagNameNS?.('*', localName) || []);
|
|
43
|
+
const valOf = element => {
|
|
44
|
+
const raw = element?.getAttribute?.('w:val') ?? element?.getAttribute?.('val');
|
|
45
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10);
|
|
46
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
50
|
+
// Canonical form
|
|
51
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function canonicalNode(node, ignoreIds) {
|
|
54
|
+
if (node.nodeType === 3 || node.nodeType === 4) {
|
|
55
|
+
const text = String(node.nodeValue || '').trim();
|
|
56
|
+
return text ? JSON.stringify(text) : '';
|
|
57
|
+
}
|
|
58
|
+
if (node.nodeType !== 1) return '';
|
|
59
|
+
const attrs = Array.from(node.attributes || [])
|
|
60
|
+
.filter(attr => !/^xmlns(:|$)/.test(attr.name))
|
|
61
|
+
.filter(attr => !(ignoreIds && /^(w:)?(abstractNumId|numId)$/.test(attr.name)))
|
|
62
|
+
.map(attr => `${attr.localName || attr.name}=${attr.value}`)
|
|
63
|
+
.sort()
|
|
64
|
+
.join(',');
|
|
65
|
+
if (ignoreIds && node.localName === 'abstractNumId' && node.parentNode?.localName === 'num') {
|
|
66
|
+
return `<abstractNumId ${attrs.replace(/val=[^,]*/, 'val=*')}/>`;
|
|
67
|
+
}
|
|
68
|
+
const children = Array.from(node.childNodes || []).map(child => canonicalNode(child, ignoreIds)).join('');
|
|
69
|
+
return `<${node.localName} ${attrs}>${children}</>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Canonical string of the definition a `numId` resolves to (the `w:num` plus its `w:abstractNum`), with the
|
|
74
|
+
* ids themselves elided so definitions can be compared across independently allocated documents.
|
|
75
|
+
* Returns null when the numId does not resolve.
|
|
76
|
+
*
|
|
77
|
+
* @param {string|Document} numbering
|
|
78
|
+
* @param {string|number} numId
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalNumberingDefinition(numbering, numId) {
|
|
81
|
+
const doc = typeof numbering === 'string' ? parseOoxml(numbering) : numbering;
|
|
82
|
+
if (!doc || hasXmlParseError(doc)) return null;
|
|
83
|
+
const num = descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
|
|
84
|
+
if (!num) return null;
|
|
85
|
+
const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
|
|
86
|
+
const abstract = descendants(doc, 'abstractNum')
|
|
87
|
+
.find(candidate => getElementId(candidate, ['w:abstractNumId', 'abstractNumId']) === abstractId);
|
|
88
|
+
return `${canonicalNode(num, true)}|${abstract ? canonicalNode(abstract, true) : 'missing-abstract'}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
92
|
+
// Create new definitions
|
|
93
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function findAbstractForNum(numberingDoc, numId) {
|
|
96
|
+
const num = descendants(numberingDoc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
|
|
97
|
+
const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
|
|
98
|
+
if (abstractId == null) return null;
|
|
99
|
+
return descendants(numberingDoc, 'abstractNum')
|
|
100
|
+
.find(candidate => getElementId(candidate, ['w:abstractNumId', 'abstractNumId']) === abstractId) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setLvlChildVal(doc, lvl, localName, value) {
|
|
104
|
+
let child = wordChildren(lvl, localName)[0];
|
|
105
|
+
if (!child) {
|
|
106
|
+
child = doc.createElementNS(WORD_MAIN_NS, `w:${localName}`);
|
|
107
|
+
const rank = LVL_CHILD_ORDER.indexOf(localName);
|
|
108
|
+
const anchor = Array.from(lvl.childNodes || []).find(candidate =>
|
|
109
|
+
candidate.nodeType === 1 && LVL_CHILD_ORDER.indexOf(candidate.localName) > rank);
|
|
110
|
+
if (anchor) lvl.insertBefore(child, anchor);
|
|
111
|
+
else lvl.appendChild(child);
|
|
112
|
+
}
|
|
113
|
+
child.setAttribute('w:val', String(value));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function defaultLevel(doc, ilvl) {
|
|
117
|
+
const lvl = doc.createElementNS(WORD_MAIN_NS, 'w:lvl');
|
|
118
|
+
lvl.setAttribute('w:ilvl', String(ilvl));
|
|
119
|
+
setLvlChildVal(doc, lvl, 'start', 1);
|
|
120
|
+
setLvlChildVal(doc, lvl, 'numFmt', 'decimal');
|
|
121
|
+
setLvlChildVal(doc, lvl, 'lvlText', `%${ilvl + 1}.`);
|
|
122
|
+
setLvlChildVal(doc, lvl, 'lvlJc', 'left');
|
|
123
|
+
const pPr = doc.createElementNS(WORD_MAIN_NS, 'w:pPr');
|
|
124
|
+
const ind = doc.createElementNS(WORD_MAIN_NS, 'w:ind');
|
|
125
|
+
ind.setAttribute('w:left', String(720 * (ilvl + 1)));
|
|
126
|
+
ind.setAttribute('w:hanging', '360');
|
|
127
|
+
pPr.appendChild(ind);
|
|
128
|
+
lvl.appendChild(pPr);
|
|
129
|
+
return lvl;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Copies the level geometry (`w:lvlJc`, `w:pPr` indentation, `w:rPr` fonts, `w:suff`) of a source `w:lvl` onto a
|
|
134
|
+
* target `w:lvl`, replacing whatever geometry the target had.
|
|
135
|
+
*
|
|
136
|
+
* @param {Element} sourceLvl
|
|
137
|
+
* @param {Element} targetLvl
|
|
138
|
+
*/
|
|
139
|
+
export function copyLevelGeometry(sourceLvl, targetLvl) {
|
|
140
|
+
const doc = targetLvl.ownerDocument;
|
|
141
|
+
for (const name of ['suff', 'lvlJc', 'pPr', 'rPr']) {
|
|
142
|
+
for (const existing of wordChildren(targetLvl, name)) targetLvl.removeChild(existing);
|
|
143
|
+
const source = wordChildren(sourceLvl, name)[0];
|
|
144
|
+
if (!source) continue;
|
|
145
|
+
const clone = doc.importNode(source, true);
|
|
146
|
+
const rank = LVL_CHILD_ORDER.indexOf(name);
|
|
147
|
+
const anchor = Array.from(targetLvl.childNodes || []).find(candidate =>
|
|
148
|
+
candidate.nodeType === 1 && LVL_CHILD_ORDER.indexOf(candidate.localName) > rank);
|
|
149
|
+
if (anchor) targetLvl.insertBefore(clone, anchor);
|
|
150
|
+
else targetLvl.appendChild(clone);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Adds `w:lvlOverride/w:startOverride` entries to a `w:num`. Per-instance restart values live on the `w:num`,
|
|
156
|
+
* never on the shared `w:abstractNum`.
|
|
157
|
+
*
|
|
158
|
+
* @param {Element} numElement
|
|
159
|
+
* @param {Record<number|string, number>} startOverrides - ilvl -> start value
|
|
160
|
+
*/
|
|
161
|
+
export function applyStartOverridesToNum(numElement, startOverrides) {
|
|
162
|
+
const doc = numElement.ownerDocument;
|
|
163
|
+
const entries = Object.entries(startOverrides || {})
|
|
164
|
+
.map(([ilvl, start]) => [Number(ilvl), Number(start)])
|
|
165
|
+
.filter(([ilvl, start]) => Number.isInteger(ilvl) && ilvl >= 0 && ilvl <= MAX_LEVEL && Number.isInteger(start) && start >= 0)
|
|
166
|
+
.sort((a, b) => a[0] - b[0]);
|
|
167
|
+
for (const [ilvl, start] of entries) {
|
|
168
|
+
for (const existing of wordChildren(numElement, 'lvlOverride')) {
|
|
169
|
+
if (valOfIlvl(existing) === ilvl) numElement.removeChild(existing);
|
|
170
|
+
}
|
|
171
|
+
const override = doc.createElementNS(WORD_MAIN_NS, 'w:lvlOverride');
|
|
172
|
+
override.setAttribute('w:ilvl', String(ilvl));
|
|
173
|
+
const startOverride = doc.createElementNS(WORD_MAIN_NS, 'w:startOverride');
|
|
174
|
+
startOverride.setAttribute('w:val', String(start));
|
|
175
|
+
override.appendChild(startOverride);
|
|
176
|
+
numElement.appendChild(override);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function valOfIlvl(element) {
|
|
181
|
+
return getElementId(element, ['w:ilvl', 'ilvl']);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Creates a NEW `w:abstractNum` and `w:num` with fresh ids reserved from the document-scoped state.
|
|
186
|
+
*
|
|
187
|
+
* Levels not redefined keep the source level untouched; redefined levels (`numFmt`, `lvlText`, `start`) keep the
|
|
188
|
+
* source level's geometry (indentation, fonts, justification). Style links, `w:nsid` and `w:tmpl` are not copied,
|
|
189
|
+
* so the new definition never aliases a style or another list's identity.
|
|
190
|
+
*
|
|
191
|
+
* @param {{
|
|
192
|
+
* numberingIdState: object,
|
|
193
|
+
* sourceNumberingXml?: string|null,
|
|
194
|
+
* sourceNumId?: number|string|null,
|
|
195
|
+
* levels?: Array<{ ilvl: number, numFmt?: string, lvlText?: string, start?: number, dropChildren?: string[], dropRunFonts?: boolean }>,
|
|
196
|
+
* startOverrides?: Record<number|string, number>
|
|
197
|
+
* }} params
|
|
198
|
+
* @returns {{ numId: number, abstractNumId: number, numberingXml: string }|null} `null` when no ids can be reserved
|
|
199
|
+
*/
|
|
200
|
+
export function allocateNumberingDefinition(params) {
|
|
201
|
+
const { numberingIdState, sourceNumberingXml, sourceNumId, levels = [], startOverrides } = params || {};
|
|
202
|
+
const pair = reserveNextNumberingIdPair(numberingIdState);
|
|
203
|
+
if (!pair) return null;
|
|
204
|
+
|
|
205
|
+
const sourceDoc = String(sourceNumberingXml || '').trim() ? parseOoxml(sourceNumberingXml) : null;
|
|
206
|
+
const sourceAbstract = sourceDoc && !hasXmlParseError(sourceDoc) && sourceNumId != null
|
|
207
|
+
? findAbstractForNum(sourceDoc, sourceNumId)
|
|
208
|
+
: null;
|
|
209
|
+
const sourceLevels = new Map(descendants(sourceAbstract, 'lvl')
|
|
210
|
+
.map(lvl => [valOfIlvl(lvl), lvl]).filter(([ilvl]) => ilvl != null));
|
|
211
|
+
|
|
212
|
+
const doc = parseOoxml(`<w:numbering xmlns:w="${WORD_MAIN_NS}"/>`);
|
|
213
|
+
const root = doc.documentElement;
|
|
214
|
+
const abstract = doc.createElementNS(WORD_MAIN_NS, 'w:abstractNum');
|
|
215
|
+
abstract.setAttribute('w:abstractNumId', String(pair.abstractNumId));
|
|
216
|
+
const multiLevelType = doc.createElementNS(WORD_MAIN_NS, 'w:multiLevelType');
|
|
217
|
+
multiLevelType.setAttribute('w:val', wordChildren(sourceAbstract, 'multiLevelType')[0]?.getAttribute('w:val') || 'multilevel');
|
|
218
|
+
abstract.appendChild(multiLevelType);
|
|
219
|
+
|
|
220
|
+
const redefined = new Map(levels.filter(level => Number.isInteger(level?.ilvl)).map(level => [level.ilvl, level]));
|
|
221
|
+
const maxLevel = Math.max(MAX_LEVEL, ...sourceLevels.keys(), ...redefined.keys());
|
|
222
|
+
for (let ilvl = 0; ilvl <= maxLevel; ilvl += 1) {
|
|
223
|
+
const source = sourceLevels.get(ilvl);
|
|
224
|
+
const lvl = source ? doc.importNode(source, true) : defaultLevel(doc, ilvl);
|
|
225
|
+
for (const pStyle of wordChildren(lvl, 'pStyle')) lvl.removeChild(pStyle);
|
|
226
|
+
lvl.removeAttribute('w:tplc');
|
|
227
|
+
lvl.removeAttribute('w:tentative');
|
|
228
|
+
const redefinition = redefined.get(ilvl);
|
|
229
|
+
if (redefinition) {
|
|
230
|
+
if (redefinition.numFmt != null) setLvlChildVal(doc, lvl, 'numFmt', redefinition.numFmt);
|
|
231
|
+
if (redefinition.lvlText != null) setLvlChildVal(doc, lvl, 'lvlText', redefinition.lvlText);
|
|
232
|
+
if (redefinition.start != null) setLvlChildVal(doc, lvl, 'start', redefinition.start);
|
|
233
|
+
// A format change must not keep children that override the new format (w:isLgl forces arabic numbers,
|
|
234
|
+
// w:lvlPicBulletId a picture bullet) or the bullet's symbol font (digits in Symbol/Wingdings).
|
|
235
|
+
for (const name of redefinition.dropChildren || []) {
|
|
236
|
+
for (const child of wordChildren(lvl, name)) lvl.removeChild(child);
|
|
237
|
+
}
|
|
238
|
+
if (redefinition.dropRunFonts) {
|
|
239
|
+
for (const rPr of wordChildren(lvl, 'rPr')) {
|
|
240
|
+
for (const fonts of wordChildren(rPr, 'rFonts')) rPr.removeChild(fonts);
|
|
241
|
+
if (!Array.from(rPr.childNodes || []).some(child => child.nodeType === 1)) lvl.removeChild(rPr);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
abstract.appendChild(lvl);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const num = doc.createElementNS(WORD_MAIN_NS, 'w:num');
|
|
249
|
+
num.setAttribute('w:numId', String(pair.numId));
|
|
250
|
+
const abstractRef = doc.createElementNS(WORD_MAIN_NS, 'w:abstractNumId');
|
|
251
|
+
abstractRef.setAttribute('w:val', String(pair.abstractNumId));
|
|
252
|
+
num.appendChild(abstractRef);
|
|
253
|
+
applyStartOverridesToNum(num, startOverrides);
|
|
254
|
+
|
|
255
|
+
root.appendChild(abstract);
|
|
256
|
+
root.appendChild(num);
|
|
257
|
+
return { numId: pair.numId, abstractNumId: pair.abstractNumId, numberingXml: serializeOoxml(doc) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
261
|
+
// Replacement policy (R1, R3)
|
|
262
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
function levelFormats(abstractNum) {
|
|
265
|
+
const formats = new Map();
|
|
266
|
+
for (const lvl of descendants(abstractNum, 'lvl')) {
|
|
267
|
+
const ilvl = valOfIlvl(lvl);
|
|
268
|
+
const fmt = descendants(lvl, 'numFmt')[0]?.getAttribute('w:val') || null;
|
|
269
|
+
if (ilvl != null && fmt) formats.set(ilvl, fmt);
|
|
270
|
+
}
|
|
271
|
+
return formats;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeListFormat(format) {
|
|
275
|
+
return format === 'bullet' || format === 'circle' || format === 'square' ? 'bullet' : format;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function currentBindings(node) {
|
|
279
|
+
return getCurrentNumIdElements(node).map(numIdElement => {
|
|
280
|
+
const ilvlElement = wordChildren(numIdElement.parentNode, 'ilvl')[0];
|
|
281
|
+
return { numIdElement, numId: valOf(numIdElement), ilvl: ilvlElement ? (valOf(ilvlElement) ?? 0) : 0 };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Policy: a same-kind list replacement continues the single list the source paragraphs already use. When every
|
|
287
|
+
* new list paragraph's generated level has the same format as that list's level, the new paragraphs bind to the
|
|
288
|
+
* existing `numId` and the generated payload is dropped. Anything else (format change, several source lists,
|
|
289
|
+
* unknown source definitions) allocates. R1: tracked-deleted paragraphs (retained source content) are never
|
|
290
|
+
* rebound.
|
|
291
|
+
*/
|
|
292
|
+
function continueSourceList(numberingXml, replacementNodes, sourceParagraphs, numberingIdState) {
|
|
293
|
+
const sourceXml = numberingIdState?.sourceNumberingXml;
|
|
294
|
+
if (!numberingXml || !String(sourceXml || '').trim() || !Array.isArray(replacementNodes)) return null;
|
|
295
|
+
|
|
296
|
+
const sourceNumIds = new Set();
|
|
297
|
+
for (const paragraph of sourceParagraphs || []) {
|
|
298
|
+
for (const { numId } of currentBindings(paragraph)) {
|
|
299
|
+
if (numId != null && numId !== 0) sourceNumIds.add(numId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (sourceNumIds.size !== 1) return null;
|
|
303
|
+
const [sourceNumId] = sourceNumIds;
|
|
304
|
+
|
|
305
|
+
const sourceDoc = parseOoxml(sourceXml);
|
|
306
|
+
const payloadDoc = parseOoxml(numberingXml);
|
|
307
|
+
if (hasXmlParseError(sourceDoc) || hasXmlParseError(payloadDoc)) return null;
|
|
308
|
+
const sourceAbstract = findAbstractForNum(sourceDoc, sourceNumId);
|
|
309
|
+
if (!sourceAbstract) return null;
|
|
310
|
+
const sourceFormats = levelFormats(sourceAbstract);
|
|
311
|
+
|
|
312
|
+
const bindings = [];
|
|
313
|
+
for (const node of replacementNodes) {
|
|
314
|
+
if (isTrackedDeletedParagraph(node)) continue;
|
|
315
|
+
bindings.push(...currentBindings(node));
|
|
316
|
+
}
|
|
317
|
+
if (bindings.length === 0) return null;
|
|
318
|
+
|
|
319
|
+
const payloadFormats = new Map();
|
|
320
|
+
for (const { numId, ilvl } of bindings) {
|
|
321
|
+
if (numId == null) return null;
|
|
322
|
+
if (!payloadFormats.has(numId)) {
|
|
323
|
+
const abstract = findAbstractForNum(payloadDoc, numId);
|
|
324
|
+
if (!abstract) return null;
|
|
325
|
+
payloadFormats.set(numId, levelFormats(abstract));
|
|
326
|
+
}
|
|
327
|
+
const generated = payloadFormats.get(numId).get(ilvl);
|
|
328
|
+
const existing = sourceFormats.get(ilvl);
|
|
329
|
+
if (!generated || !existing || normalizeListFormat(generated) !== normalizeListFormat(existing)) return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const clonedNodes = replacementNodes.map(node => (node?.cloneNode ? node.cloneNode(true) : node));
|
|
333
|
+
for (const node of clonedNodes) {
|
|
334
|
+
if (isTrackedDeletedParagraph(node)) continue;
|
|
335
|
+
for (const numIdElement of getCurrentNumIdElements(node)) numIdElement.setAttribute('w:val', String(sourceNumId));
|
|
336
|
+
}
|
|
337
|
+
return { numId: sourceNumId, replacementNodes: clonedNodes };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolves the numbering of generated replacement paragraphs against the document.
|
|
342
|
+
*
|
|
343
|
+
* Policy order: (1) `continue-source` when `allowContinueSource` and the replacement is the same kind of list as
|
|
344
|
+
* the single source list; (2) `allocate`: remap the generated definitions onto fresh ids from the document-scoped
|
|
345
|
+
* state; (3) `passthrough` when there is nothing to allocate against. Tracked-deleted source paragraphs keep their
|
|
346
|
+
* `numPr` in every case (R1).
|
|
347
|
+
*
|
|
348
|
+
* @param {{
|
|
349
|
+
* numberingXml: string|null,
|
|
350
|
+
* replacementNodes: Node[],
|
|
351
|
+
* sourceParagraphs?: Node[],
|
|
352
|
+
* numberingIdState?: object|null,
|
|
353
|
+
* allowContinueSource?: boolean
|
|
354
|
+
* }} params
|
|
355
|
+
* @returns {{ policy: 'continue-source'|'allocate'|'passthrough', numberingXml: string|null, replacementNodes: Node[], numId?: number }}
|
|
356
|
+
*/
|
|
357
|
+
export function allocateNumberingForReplacement(params) {
|
|
358
|
+
const { numberingXml, replacementNodes, sourceParagraphs, numberingIdState, allowContinueSource = true } = params || {};
|
|
359
|
+
if (numberingXml && numberingIdState && allowContinueSource) {
|
|
360
|
+
const continued = continueSourceList(numberingXml, replacementNodes, sourceParagraphs, numberingIdState);
|
|
361
|
+
if (continued) {
|
|
362
|
+
return { policy: 'continue-source', numberingXml: null, replacementNodes: continued.replacementNodes, numId: continued.numId };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (numberingXml && numberingIdState) {
|
|
366
|
+
const remapped = remapNumberingPayloadForDocument(numberingXml, replacementNodes, numberingIdState);
|
|
367
|
+
return { policy: 'allocate', numberingXml: remapped.numberingXml, replacementNodes: remapped.replacementNodes };
|
|
368
|
+
}
|
|
369
|
+
return { policy: 'passthrough', numberingXml: numberingXml ?? null, replacementNodes };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Same key `list-format convert-plain` records, so redline conversions and list-format share one sequence. */
|
|
373
|
+
function batchSequenceKey(numFmt, ilvl, lvlText) {
|
|
374
|
+
const terminator = String(lvlText || '').replace(/%\d+/g, '').trim() || '.';
|
|
375
|
+
return `list-format:${numFmt}:${ilvl}:${numFmt === 'bullet' ? '' : terminator}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Describes the single list a generated payload creates: every new (not tracked-deleted) list paragraph binds one
|
|
380
|
+
* `numId` at one level. Returns null for nested, multi-list or unresolvable payloads (those never join a sequence).
|
|
381
|
+
*/
|
|
382
|
+
function describeGeneratedList(numberingXml, replacementNodes) {
|
|
383
|
+
const bindings = [];
|
|
384
|
+
for (const node of replacementNodes || []) {
|
|
385
|
+
if (isTrackedDeletedParagraph(node)) continue;
|
|
386
|
+
bindings.push(...currentBindings(node));
|
|
387
|
+
}
|
|
388
|
+
if (bindings.length === 0) return null;
|
|
389
|
+
const { numId, ilvl } = bindings[0];
|
|
390
|
+
if (numId == null || numId === 0 || bindings.some(binding => binding.numId !== numId || binding.ilvl !== ilvl)) return null;
|
|
391
|
+
const doc = parseOoxml(numberingXml);
|
|
392
|
+
if (hasXmlParseError(doc)) return null;
|
|
393
|
+
const num = descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
|
|
394
|
+
const abstract = findAbstractForNum(doc, numId);
|
|
395
|
+
const lvl = descendants(abstract, 'lvl').find(candidate => valOfIlvl(candidate) === ilvl);
|
|
396
|
+
const numFmt = descendants(lvl, 'numFmt')[0]?.getAttribute('w:val');
|
|
397
|
+
if (!num || !lvl || !numFmt) return null;
|
|
398
|
+
const lvlText = descendants(lvl, 'lvlText')[0]?.getAttribute('w:val') || '';
|
|
399
|
+
const override = wordChildren(num, 'lvlOverride').find(candidate => valOfIlvl(candidate) === ilvl);
|
|
400
|
+
const start = valOf(wordChildren(override, 'startOverride')[0]) ?? valOf(descendants(lvl, 'start')[0]) ?? 1;
|
|
401
|
+
const isBullet = normalizeListFormat(numFmt) === 'bullet';
|
|
402
|
+
return {
|
|
403
|
+
numId,
|
|
404
|
+
ilvl,
|
|
405
|
+
count: bindings.length,
|
|
406
|
+
isBullet,
|
|
407
|
+
start,
|
|
408
|
+
key: batchSequenceKey(isBullet ? 'bullet' : numFmt, ilvl, lvlText)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Batch sequence: separate operations of one batch that generate items of the same kind, format and level continue
|
|
414
|
+
* ONE numbering instance while their values continue (`A.` then `B.`; bullets always). A gap or a different format
|
|
415
|
+
* allocates a new instance. The previous entry is reused only while its numId is still bound in the document
|
|
416
|
+
* (`isNumIdLive`), so a refused or rolled-back operation cannot poison the sequence. The sequence is the one
|
|
417
|
+
* `list-format convert-plain` uses (same keys), so ids still come from the document-scoped allocator: a continued
|
|
418
|
+
* list reuses an id that was already allocated, and a new list keeps the id the caller already reserved.
|
|
419
|
+
* Returns `{ numId, replacementNodes }` rebound to the previous instance (the caller drops the generated payload),
|
|
420
|
+
* or null when the caller keeps its own allocation (which this call has recorded as the new sequence entry).
|
|
421
|
+
*/
|
|
422
|
+
export function continueBatchListSequence({ numberingXml, replacementNodes, sequence, isNumIdLive }) {
|
|
423
|
+
if (!(sequence instanceof Map) || !numberingXml) return null;
|
|
424
|
+
const described = describeGeneratedList(numberingXml, replacementNodes);
|
|
425
|
+
if (!described) return null;
|
|
426
|
+
const previous = sequence.get(described.key) || null;
|
|
427
|
+
const live = previous && previous.numId != null && (typeof isNumIdLive !== 'function' || isNumIdLive(previous.numId));
|
|
428
|
+
const continues = live && (described.isBullet || previous.nextStartAt === described.start);
|
|
429
|
+
const nextStartAt = described.isBullet ? null : described.start + described.count;
|
|
430
|
+
if (!continues) {
|
|
431
|
+
sequence.set(described.key, { numId: String(described.numId), nextStartAt });
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const clonedNodes = replacementNodes.map(node => (node?.cloneNode ? node.cloneNode(true) : node));
|
|
435
|
+
for (const node of clonedNodes) {
|
|
436
|
+
if (isTrackedDeletedParagraph(node)) continue;
|
|
437
|
+
for (const numIdElement of getCurrentNumIdElements(node)) numIdElement.setAttribute('w:val', String(previous.numId));
|
|
438
|
+
}
|
|
439
|
+
sequence.set(described.key, { numId: String(previous.numId), nextStartAt });
|
|
440
|
+
return { numId: previous.numId, replacementNodes: clonedNodes };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
444
|
+
// Merge (R2): the only numbering merge in the package
|
|
445
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
function insertNumberingNodeInSchemaOrder(root, node, kind) {
|
|
448
|
+
const directChildren = Array.from(root.childNodes || []).filter(
|
|
449
|
+
child => child && child.nodeType === 1 && child.namespaceURI === WORD_MAIN_NS
|
|
450
|
+
);
|
|
451
|
+
const anchor = kind === 'abstract'
|
|
452
|
+
? directChildren.find(child => child.localName === 'num' || child.localName === 'numIdMacAtCleanup')
|
|
453
|
+
: directChildren.find(child => child.localName === 'numIdMacAtCleanup');
|
|
454
|
+
if (anchor) root.insertBefore(node, anchor);
|
|
455
|
+
else root.appendChild(node);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Merges incoming numbering definitions into an existing numbering part, preserving schema order
|
|
460
|
+
* (`abstractNum*` before `num*`). Definitions are added by id; an incoming id that already exists is kept as is
|
|
461
|
+
* when its canonical content is equal, and is reported through `onConflict` (never overwritten) when it differs,
|
|
462
|
+
* because the paragraphs written with the incoming id would silently bind to someone else's list.
|
|
463
|
+
*
|
|
464
|
+
* Every caller (standalone runner consumers, `openDocx`, `ensureNumberingArtifactsInZip`) merges through here.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} existingNumberingXml
|
|
467
|
+
* @param {string} incomingNumberingXml
|
|
468
|
+
* @param {{ onConflict?: (conflict: { kind: 'abstractNum'|'num', id: number }) => void }} [options]
|
|
469
|
+
* @returns {string}
|
|
470
|
+
*/
|
|
471
|
+
export function mergeNumberingXmlBySchemaOrder(existingNumberingXml, incomingNumberingXml, options = {}) {
|
|
472
|
+
const existingText = String(existingNumberingXml || '');
|
|
473
|
+
const incomingText = String(incomingNumberingXml || '');
|
|
474
|
+
if (!incomingText.trim()) return existingText;
|
|
475
|
+
if (!existingText.trim()) return incomingText;
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const existingDoc = parseOoxml(existingText);
|
|
479
|
+
const incomingDoc = parseOoxml(incomingText);
|
|
480
|
+
if (hasXmlParseError(existingDoc) || hasXmlParseError(incomingDoc)) return existingText;
|
|
481
|
+
const existingRoot = existingDoc.documentElement;
|
|
482
|
+
const incomingRoot = incomingDoc.documentElement;
|
|
483
|
+
if (!existingRoot || !incomingRoot) return existingText;
|
|
484
|
+
|
|
485
|
+
for (const [kind, localName, attr] of [['abstract', 'abstractNum', 'abstractNumId'], ['num', 'num', 'numId']]) {
|
|
486
|
+
const existingById = new Map(wordChildren(existingRoot, localName)
|
|
487
|
+
.map(node => [getElementId(node, [`w:${attr}`, attr]), node]).filter(([id]) => id != null));
|
|
488
|
+
for (const incomingNode of wordChildren(incomingRoot, localName)) {
|
|
489
|
+
const id = getElementId(incomingNode, [`w:${attr}`, attr]);
|
|
490
|
+
if (id == null) continue;
|
|
491
|
+
const present = existingById.get(id);
|
|
492
|
+
if (present) {
|
|
493
|
+
if (typeof options?.onConflict === 'function' && canonicalNode(present, false) !== canonicalNode(incomingNode, false)) {
|
|
494
|
+
options.onConflict({ kind: localName, id });
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const imported = existingDoc.importNode(incomingNode, true);
|
|
499
|
+
insertNumberingNodeInSchemaOrder(existingRoot, imported, kind);
|
|
500
|
+
existingById.set(id, imported);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return serializeOoxml(existingDoc);
|
|
504
|
+
} catch {
|
|
505
|
+
return existingText;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Merges every incoming numbering payload into the existing part, in order. Same arguments for every caller.
|
|
511
|
+
*
|
|
512
|
+
* @param {string|null|undefined} existingNumberingXml
|
|
513
|
+
* @param {string|string[]|null|undefined} incomingParts
|
|
514
|
+
* @param {Parameters<typeof mergeNumberingXmlBySchemaOrder>[2]} [options]
|
|
515
|
+
* @returns {string}
|
|
516
|
+
*/
|
|
517
|
+
export function mergeNumberingParts(existingNumberingXml, incomingParts, options = {}) {
|
|
518
|
+
let merged = String(existingNumberingXml || '');
|
|
519
|
+
for (const part of (Array.isArray(incomingParts) ? incomingParts : [incomingParts]).filter(Boolean)) {
|
|
520
|
+
merged = mergeNumberingXmlBySchemaOrder(merged, part, options);
|
|
521
|
+
}
|
|
522
|
+
return merged;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
526
|
+
// Validation
|
|
527
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Checks that a direct paragraph binding `{ numId, level }` resolves in the document's numbering: the `w:num`
|
|
531
|
+
* exists (in the source part or among ids the current batch allocated), it has an `abstractNum`, and the level
|
|
532
|
+
* is defined by that abstract or by the `w:num`'s own `w:lvlOverride/w:lvl`. A binding is a reference, so this
|
|
533
|
+
* never allocates.
|
|
534
|
+
*
|
|
535
|
+
* @param {{ sourceNumberingXml?: string|null, usedNumIds?: Set<number> }|null|undefined} numberingIdState
|
|
536
|
+
* @param {string|number} numId
|
|
537
|
+
* @param {number} [level]
|
|
538
|
+
* @returns {{ ok: true } | { ok: false, reason: 'unknown-num'|'missing-abstract'|'undefined-level', message: string }}
|
|
539
|
+
*/
|
|
540
|
+
export function checkNumberingBinding(numberingIdState, numId, level = 0) {
|
|
541
|
+
const id = Number(numId);
|
|
542
|
+
const sourceXml = String(numberingIdState?.sourceNumberingXml || '');
|
|
543
|
+
const doc = sourceXml.trim() ? parseOoxml(sourceXml) : null;
|
|
544
|
+
const sourceNum = doc && !hasXmlParseError(doc)
|
|
545
|
+
? descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === id)
|
|
546
|
+
: null;
|
|
547
|
+
if (!sourceNum) {
|
|
548
|
+
// Ids allocated earlier in this batch exist only as pending numbering parts.
|
|
549
|
+
if (numberingIdState?.usedNumIds instanceof Set && numberingIdState.usedNumIds.has(id)) return { ok: true };
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
reason: 'unknown-num',
|
|
553
|
+
message: `numbering.numId ${id} does not exist in the document's numbering definitions.`
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const abstract = findAbstractForNum(doc, id);
|
|
557
|
+
if (!abstract) {
|
|
558
|
+
return { ok: false, reason: 'missing-abstract', message: `numId ${id} points at an abstractNum that does not exist.` };
|
|
559
|
+
}
|
|
560
|
+
const definesLevel = container => wordChildren(container, 'lvl')
|
|
561
|
+
.some(lvl => Number.parseInt(lvl.getAttribute('w:ilvl') ?? lvl.getAttribute('ilvl') ?? '0', 10) === level);
|
|
562
|
+
const overrideDefines = wordChildren(sourceNum, 'lvlOverride').some(override => definesLevel(override));
|
|
563
|
+
if (!definesLevel(abstract) && !overrideDefines) {
|
|
564
|
+
return { ok: false, reason: 'undefined-level', message: `numId ${id} does not define list level ${level}.` };
|
|
565
|
+
}
|
|
566
|
+
return { ok: true };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Numbering integrity: every body `w:numId` (current and `pPrChange` snapshot) resolves to a `w:num`, every
|
|
571
|
+
* `w:num` resolves to an `abstractNum`, every `w:ilvl` in use exists in that abstract (or in the `w:num`'s own
|
|
572
|
+
* `w:lvlOverride/w:lvl`), and numbering ids are unique. `numId` 0 means "no list" and is always valid.
|
|
573
|
+
*
|
|
574
|
+
* @param {string|Document} documentXml
|
|
575
|
+
* @param {string|Document|null|undefined} numberingXml
|
|
576
|
+
* @returns {Array<{ code: string, severity: 'error', message: string, numId?: number, abstractNumId?: number, ilvl?: number }>}
|
|
577
|
+
*/
|
|
578
|
+
export function validateNumberingIntegrity(documentXml, numberingXml) {
|
|
579
|
+
const issues = [];
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
const report = (code, message, extra = {}) => {
|
|
582
|
+
const key = `${code}:${extra.numId ?? ''}:${extra.abstractNumId ?? ''}:${extra.ilvl ?? ''}`;
|
|
583
|
+
if (seen.has(key)) return;
|
|
584
|
+
seen.add(key);
|
|
585
|
+
issues.push({ code, severity: 'error', message, ...extra });
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const bodyDoc = typeof documentXml === 'string' ? parseOoxml(documentXml) : documentXml;
|
|
589
|
+
if (!bodyDoc || hasXmlParseError(bodyDoc)) return issues;
|
|
590
|
+
const numberingDoc = typeof numberingXml === 'string'
|
|
591
|
+
? (numberingXml.trim() ? parseOoxml(numberingXml) : null)
|
|
592
|
+
: (numberingXml || null);
|
|
593
|
+
const numberingOk = numberingDoc && !hasXmlParseError(numberingDoc);
|
|
594
|
+
|
|
595
|
+
const abstractById = new Map();
|
|
596
|
+
const numById = new Map();
|
|
597
|
+
if (numberingOk) {
|
|
598
|
+
for (const [localName, attr, map] of [['abstractNum', 'abstractNumId', abstractById], ['num', 'numId', numById]]) {
|
|
599
|
+
for (const node of descendants(numberingDoc, localName)) {
|
|
600
|
+
const id = getElementId(node, [`w:${attr}`, attr]);
|
|
601
|
+
if (id == null) continue;
|
|
602
|
+
if (map.has(id)) report('NUMBERING_DUPLICATE_ID', `Numbering part defines ${localName} ${id} more than once.`, { [attr]: id });
|
|
603
|
+
else map.set(id, node);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const [numId, num] of numById) {
|
|
607
|
+
const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
|
|
608
|
+
if (abstractId == null) {
|
|
609
|
+
report('NUMBERING_ABSTRACT_UNDEFINED', `w:num ${numId} has no w:abstractNumId.`, { numId });
|
|
610
|
+
} else if (!abstractById.has(abstractId)) {
|
|
611
|
+
report('NUMBERING_ABSTRACT_UNDEFINED', `w:num ${numId} references missing w:abstractNum ${abstractId}.`, { numId, abstractNumId: abstractId });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const levelDefined = (numId, ilvl) => {
|
|
617
|
+
const num = numById.get(numId);
|
|
618
|
+
const abstract = abstractById.get(valOf(wordChildren(num, 'abstractNumId')[0]));
|
|
619
|
+
if (!abstract) return true; // already reported as an undefined abstractNum
|
|
620
|
+
if (descendants(abstract, 'lvl').some(lvl => valOfIlvl(lvl) === ilvl)) return true;
|
|
621
|
+
return wordChildren(num, 'lvlOverride').some(override =>
|
|
622
|
+
valOfIlvl(override) === ilvl && wordChildren(override, 'lvl').length > 0);
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
for (const paragraph of Array.from(bodyDoc.getElementsByTagNameNS(WORD_MAIN_NS, 'p'))) {
|
|
626
|
+
const bindings = [getCurrentParagraphProperties(paragraph).numPr, getHistoricalParagraphProperties(paragraph)?.numPr];
|
|
627
|
+
for (const numPr of bindings) {
|
|
628
|
+
if (!numPr?.numIdElement) continue;
|
|
629
|
+
const numId = valOf(numPr.numIdElement);
|
|
630
|
+
if (numId == null || numId === 0) continue;
|
|
631
|
+
if (!numberingOk || !numById.has(numId)) {
|
|
632
|
+
report('NUMBERING_NUM_UNDEFINED', `w:numId ${numId} is used in the document but no w:num defines it.`, { numId });
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const ilvl = numPr.ilvlElement ? valOf(numPr.ilvlElement) : null;
|
|
636
|
+
if (ilvl != null && !levelDefined(numId, ilvl)) {
|
|
637
|
+
report('NUMBERING_LEVEL_UNDEFINED', `w:ilvl ${ilvl} is used with w:numId ${numId} but its abstractNum does not define that level.`, { numId, ilvl });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return issues;
|
|
642
|
+
}
|