@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `list-format` operation: contract shape, typed-marker grammar and numbering level specs (canonical list
|
|
3
|
+
* operations plan, WP-4). Pure helpers only; the DOM mutation lives in `document-operation-mutations.js`.
|
|
4
|
+
*
|
|
5
|
+
* `convert-plain` binds plain paragraphs to a NEW list definition. With `strip: 'marker'` (default) every
|
|
6
|
+
* paragraph's typed marker (`A.`, `1)`, `iv.`, `-`) states the list format and the item's value; the marker is
|
|
7
|
+
* removed with a tracked deletion and the binding is recorded as a tracked `w:pPrChange`. With `strip: 'none'`
|
|
8
|
+
* the text is left alone and `list.format` / `list.start` state the format and first value.
|
|
9
|
+
*
|
|
10
|
+
* The grammar is deliberately narrow: anything it cannot classify exactly is refused with a recovery-enveloped
|
|
11
|
+
* code rather than guessed (single roman-looking letters, mixed families, multi-level `2.1.`, `(a)`).
|
|
12
|
+
*
|
|
13
|
+
* `change-format` and `join` (WP-5, capability `list-format-change-v1`) rebind existing list items; their DOM core is
|
|
14
|
+
* `services/list-format-change.js`. This module validates their shape (`scope`, `lvlText`, `continueFrom`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const LIST_FORMAT_MODES = Object.freeze(['convert-plain', 'change-format', 'join']);
|
|
18
|
+
export const LIST_FORMAT_FORMATS = Object.freeze(['decimal', 'upperLetter', 'lowerLetter', 'upperRoman', 'lowerRoman', 'bullet']);
|
|
19
|
+
export const LIST_FORMAT_STRIP_VALUES = Object.freeze(['marker', 'none']);
|
|
20
|
+
export const LIST_FORMAT_SCOPES = Object.freeze(['list', 'range']);
|
|
21
|
+
/** Keys each mode accepts (`list-format-v1`: convert-plain; `list-format-change-v1`: change-format and join). */
|
|
22
|
+
const MODE_KEYS = Object.freeze({
|
|
23
|
+
'convert-plain': Object.freeze(['mode', 'kind', 'format', 'start', 'level', 'strip']),
|
|
24
|
+
'change-format': Object.freeze(['mode', 'kind', 'format', 'lvlText', 'start', 'level', 'scope']),
|
|
25
|
+
join: Object.freeze(['mode', 'continueFrom', 'scope'])
|
|
26
|
+
});
|
|
27
|
+
/** Keys of the planned contract that a later work package implements (detach). */
|
|
28
|
+
const LATER_KEYS = Object.freeze(['materializeMarker']);
|
|
29
|
+
const ALL_KEYS = new Set([...Object.values(MODE_KEYS).flat(), ...LATER_KEYS]);
|
|
30
|
+
|
|
31
|
+
export const LIST_MARKER_NOT_FOUND = 'LIST_MARKER_NOT_FOUND';
|
|
32
|
+
export const LIST_MARKER_AMBIGUOUS = 'LIST_MARKER_AMBIGUOUS';
|
|
33
|
+
export const LIST_MARKER_UNSUPPORTED = 'LIST_MARKER_UNSUPPORTED';
|
|
34
|
+
export const LIST_FORMAT_TARGET_IS_LIST = 'LIST_FORMAT_TARGET_IS_LIST';
|
|
35
|
+
export const LIST_FORMAT_STYLE_NUMBERED = 'LIST_FORMAT_STYLE_NUMBERED';
|
|
36
|
+
// change-format and join (list-format-change-v1)
|
|
37
|
+
export const LIST_FORMAT_TARGET_NOT_LIST = 'LIST_FORMAT_TARGET_NOT_LIST';
|
|
38
|
+
export const LIST_FORMAT_MIXED_RANGE = 'LIST_FORMAT_MIXED_RANGE';
|
|
39
|
+
export const LIST_FORMAT_STYLE_LINKED = 'LIST_FORMAT_STYLE_LINKED';
|
|
40
|
+
export const LIST_FORMAT_SPLIT_UNSUPPORTED = 'LIST_FORMAT_SPLIT_UNSUPPORTED';
|
|
41
|
+
export const LIST_FORMAT_DEFINITION_UNSUPPORTED = 'LIST_FORMAT_DEFINITION_UNSUPPORTED';
|
|
42
|
+
export const LIST_FORMAT_JOIN_INVALID = 'LIST_FORMAT_JOIN_INVALID';
|
|
43
|
+
export const LIST_FORMAT_UNCHANGED = 'LIST_FORMAT_UNCHANGED';
|
|
44
|
+
|
|
45
|
+
const BULLET_CHARS = '•\\-*–·▪◦';
|
|
46
|
+
const SPACE = '[ \\t\\u00a0]';
|
|
47
|
+
const NUMBERED_MARKER = new RegExp(`^(${SPACE}*)(?:(\\d{1,3})|([A-Za-z]{1,7}))([.)])(${SPACE}+)(?=\\S)`);
|
|
48
|
+
const BULLET_MARKER = new RegExp(`^(${SPACE}*)([${BULLET_CHARS}])(${SPACE}+)(?=\\S)`);
|
|
49
|
+
const MULTILEVEL_MARKER = new RegExp(`^${SPACE}*\\d+(?:\\.\\d+)+\\.?${SPACE}+\\S`);
|
|
50
|
+
const PARENTHESIZED_MARKER = new RegExp(`^${SPACE}*\\((?:\\d{1,3}|[A-Za-z]{1,7})\\)${SPACE}+\\S`);
|
|
51
|
+
const ROMAN = /^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/;
|
|
52
|
+
const ROMAN_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
|
|
53
|
+
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fieldError = (message, field) => ({ message, field });
|
|
59
|
+
|
|
60
|
+
function validateFormatAndKind(list) {
|
|
61
|
+
if (list.format != null && !LIST_FORMAT_FORMATS.includes(list.format)) {
|
|
62
|
+
return fieldError(`list.format must be one of: ${LIST_FORMAT_FORMATS.join(', ')}.`, 'list.format');
|
|
63
|
+
}
|
|
64
|
+
if (list.kind != null && list.kind !== 'bullet' && list.kind !== 'numbered') {
|
|
65
|
+
return fieldError('list.kind must be "bullet" or "numbered".', 'list.kind');
|
|
66
|
+
}
|
|
67
|
+
if (list.kind != null && list.format != null && (list.kind === 'bullet') !== (list.format === 'bullet')) {
|
|
68
|
+
return fieldError(`list.kind "${list.kind}" contradicts list.format "${list.format}".`, 'list.kind');
|
|
69
|
+
}
|
|
70
|
+
if (list.level != null && !(Number.isInteger(list.level) && list.level >= 0 && list.level <= 8)) {
|
|
71
|
+
return fieldError('list.level must be an integer from 0 to 8.', 'list.level');
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks a `w:lvlText` value. Numbered levels use `%1`..`%9` placeholders and, once the level is known, must contain
|
|
78
|
+
* their own placeholder and no deeper one; bullet levels are one to four literal characters. Returns a message or null.
|
|
79
|
+
*/
|
|
80
|
+
export function validateLevelText(lvlText, { bullet = false, level = null } = {}) {
|
|
81
|
+
if (typeof lvlText !== 'string' || lvlText.length < 1 || lvlText.length > 32) return 'list.lvlText must be a string of 1 to 32 characters.';
|
|
82
|
+
const placeholders = lvlText.match(/%./g) || [];
|
|
83
|
+
if (bullet) {
|
|
84
|
+
if (lvlText.includes('%') || lvlText.length > 4) return 'A bullet list.lvlText is the bullet itself: 1 to 4 characters without % placeholders.';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (placeholders.length === 0) return 'A numbered list.lvlText needs a %1..%9 placeholder (for example "%1." or "(%1)").';
|
|
88
|
+
if (placeholders.some(item => !/^%[1-9]$/.test(item))) return 'list.lvlText placeholders must be %1 to %9.';
|
|
89
|
+
if (level != null) {
|
|
90
|
+
if (!placeholders.includes(`%${level + 1}`)) return `list.lvlText for level ${level} must contain its own placeholder %${level + 1}.`;
|
|
91
|
+
if (placeholders.some(item => Number(item.slice(1)) > level + 1)) return `list.lvlText for level ${level} cannot reference a deeper level than %${level + 1}.`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateContinueFrom(target) {
|
|
97
|
+
if (!isRecord(target)) {
|
|
98
|
+
return fieldError('join needs list.continueFrom: a paragraph target { paragraphId, fingerprint?, exactText? } of an item of the list to continue.', 'list.continueFrom');
|
|
99
|
+
}
|
|
100
|
+
const strong = ['paragraphId', 'fingerprint', 'exactText', 'text'].some(key => typeof target[key] === 'string' && target[key].trim());
|
|
101
|
+
if (!strong) return fieldError('list.continueFrom needs paragraphId, fingerprint or exactText.', 'list.continueFrom');
|
|
102
|
+
if (target.captureRef != null || target.occurrence != null || (target.revisionView != null && target.revisionView !== 'accepted')) {
|
|
103
|
+
return fieldError('list.continueFrom targets a batch-start accepted-view paragraph (no captureRef, occurrence or rejected view).', 'list.continueFrom');
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Validates `operation.list` for `list-format`. Returns an error message (with `field`) or null. */
|
|
109
|
+
export function validateListFormatShape(list) {
|
|
110
|
+
if (!isRecord(list)) return fieldError('list-format operations require a "list" object.', 'list');
|
|
111
|
+
const unknown = Object.keys(list).filter(key => !ALL_KEYS.has(key));
|
|
112
|
+
if (unknown.length > 0) return fieldError(`list has unsupported ${unknown.length === 1 ? 'key' : 'keys'}: ${unknown.join(', ')}.`, `list.${unknown[0]}`);
|
|
113
|
+
const later = Object.keys(list).filter(key => LATER_KEYS.includes(key));
|
|
114
|
+
if (later.length > 0) return fieldError(`list.${later[0]} is not supported yet (detach is a later release).`, `list.${later[0]}`);
|
|
115
|
+
if (!LIST_FORMAT_MODES.includes(list.mode)) {
|
|
116
|
+
return fieldError(`list.mode must be one of: ${LIST_FORMAT_MODES.join(', ')} (detach is not available yet).`, 'list.mode');
|
|
117
|
+
}
|
|
118
|
+
const foreign = Object.keys(list).filter(key => !MODE_KEYS[list.mode].includes(key));
|
|
119
|
+
if (foreign.length > 0) {
|
|
120
|
+
return fieldError(`list.${foreign[0]} does not apply to mode "${list.mode}" (it accepts: ${MODE_KEYS[list.mode].filter(key => key !== 'mode').join(', ')}).`, `list.${foreign[0]}`);
|
|
121
|
+
}
|
|
122
|
+
if (list.scope != null && !LIST_FORMAT_SCOPES.includes(list.scope)) {
|
|
123
|
+
return fieldError('list.scope must be "list" (every paragraph of the target\'s list) or "range" (only the target range).', 'list.scope');
|
|
124
|
+
}
|
|
125
|
+
const shapeError = validateFormatAndKind(list);
|
|
126
|
+
if (shapeError) return shapeError;
|
|
127
|
+
|
|
128
|
+
if (list.mode === 'join') return validateContinueFrom(list.continueFrom);
|
|
129
|
+
|
|
130
|
+
if (list.mode === 'change-format') {
|
|
131
|
+
if (list.format == null && list.kind == null && list.lvlText == null && list.start == null) {
|
|
132
|
+
return fieldError('change-format needs at least one of list.format, list.kind, list.lvlText or list.start.', 'list.format');
|
|
133
|
+
}
|
|
134
|
+
if (list.kind === 'numbered' && list.format == null) {
|
|
135
|
+
return fieldError('kind "numbered" does not say which numbering format; pass list.format (decimal, upperLetter, lowerLetter, upperRoman or lowerRoman).', 'list.format');
|
|
136
|
+
}
|
|
137
|
+
if (list.lvlText != null) {
|
|
138
|
+
const bullet = (list.format ?? (list.kind === 'bullet' ? 'bullet' : null)) === 'bullet';
|
|
139
|
+
const message = validateLevelText(list.lvlText, { bullet, level: Number.isInteger(list.level) ? list.level : null });
|
|
140
|
+
if (message) return fieldError(message, 'list.lvlText');
|
|
141
|
+
}
|
|
142
|
+
if (list.start != null) {
|
|
143
|
+
if (!(Number.isInteger(list.start) && list.start >= 1 && list.start <= 32767)) return fieldError('list.start must be an integer from 1 to 32767.', 'list.start');
|
|
144
|
+
if (list.scope === 'range') {
|
|
145
|
+
return fieldError('scope "range" continues the source count across the split; list.start applies to scope "list" only.', 'list.start');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (list.strip != null && !LIST_FORMAT_STRIP_VALUES.includes(list.strip)) {
|
|
152
|
+
return { message: 'list.strip must be "marker" or "none".', field: 'list.strip' };
|
|
153
|
+
}
|
|
154
|
+
const strip = list.strip || 'marker';
|
|
155
|
+
if (list.start != null) {
|
|
156
|
+
if (strip === 'marker') {
|
|
157
|
+
return { message: 'list.start is derived from the typed marker when strip is "marker"; omit it, or use strip: "none".', field: 'list.start' };
|
|
158
|
+
}
|
|
159
|
+
if (!(Number.isInteger(list.start) && list.start >= 1 && list.start <= 32767)) {
|
|
160
|
+
return { message: 'list.start must be an integer from 1 to 32767.', field: 'list.start' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (strip === 'none' && list.format == null && list.kind !== 'bullet') {
|
|
164
|
+
return { message: 'strip: "none" leaves the text alone, so list.format (or kind: "bullet") must state the list format.', field: 'list.format' };
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Normalized `list` (defaults applied); call after `validateListFormatShape`. */
|
|
170
|
+
export function normalizeListFormatRequest(list) {
|
|
171
|
+
const format = list.format || (list.kind === 'bullet' ? 'bullet' : null);
|
|
172
|
+
return {
|
|
173
|
+
mode: list.mode,
|
|
174
|
+
format,
|
|
175
|
+
kind: format ? (format === 'bullet' ? 'bullet' : 'numbered') : (list.kind || null),
|
|
176
|
+
level: Number.isInteger(list.level) ? list.level : 0,
|
|
177
|
+
// change-format: null means "the level the target paragraphs use".
|
|
178
|
+
explicitLevel: Number.isInteger(list.level) ? list.level : null,
|
|
179
|
+
strip: list.strip || 'marker',
|
|
180
|
+
start: Number.isInteger(list.start) ? list.start : null,
|
|
181
|
+
lvlText: typeof list.lvlText === 'string' ? list.lvlText : null,
|
|
182
|
+
scope: list.scope || 'list',
|
|
183
|
+
continueFrom: list.continueFrom ?? null
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function romanValue(letters) {
|
|
188
|
+
const upper = letters.toUpperCase();
|
|
189
|
+
if (!upper || !ROMAN.test(upper)) return null;
|
|
190
|
+
let total = 0;
|
|
191
|
+
for (let i = 0; i < upper.length; i += 1) {
|
|
192
|
+
const value = ROMAN_VALUES[upper[i]];
|
|
193
|
+
const next = ROMAN_VALUES[upper[i + 1]] || 0;
|
|
194
|
+
total += value < next ? -value : value;
|
|
195
|
+
}
|
|
196
|
+
return total;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parses the typed marker at the start of a paragraph's text.
|
|
201
|
+
*
|
|
202
|
+
* Returns `null` when the text has no marker, `{ unsupported: reason, marker }` for a marker shape list-format-v1
|
|
203
|
+
* refuses, or `{ marker, terminator, prefixLength, candidates: [{ format, value }], bulletChar? }`. `prefixLength`
|
|
204
|
+
* covers leading spaces, the marker and the whitespace after it: exactly the text a strip removes. A single
|
|
205
|
+
* letter that is also a roman numeral (`I.`, `C.`) has two candidates; the caller disambiguates.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} text
|
|
208
|
+
*/
|
|
209
|
+
export function parseTypedListMarker(text) {
|
|
210
|
+
const value = String(text ?? '');
|
|
211
|
+
if (MULTILEVEL_MARKER.test(value)) {
|
|
212
|
+
return { unsupported: 'multilevel', marker: value.trim().split(/\s/)[0] };
|
|
213
|
+
}
|
|
214
|
+
if (PARENTHESIZED_MARKER.test(value)) {
|
|
215
|
+
return { unsupported: 'parenthesized', marker: value.trim().split(/\s/)[0] };
|
|
216
|
+
}
|
|
217
|
+
const bullet = BULLET_MARKER.exec(value);
|
|
218
|
+
if (bullet) {
|
|
219
|
+
return {
|
|
220
|
+
marker: bullet[2],
|
|
221
|
+
terminator: '',
|
|
222
|
+
bulletChar: bullet[2],
|
|
223
|
+
prefixLength: bullet[0].length,
|
|
224
|
+
candidates: [{ format: 'bullet', value: null }]
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const numbered = NUMBERED_MARKER.exec(value);
|
|
228
|
+
if (!numbered) return null;
|
|
229
|
+
const [whole, , digits, letters, terminator] = numbered;
|
|
230
|
+
const marker = `${digits ?? letters}${terminator}`;
|
|
231
|
+
const base = { marker, terminator, prefixLength: whole.length };
|
|
232
|
+
if (digits != null) {
|
|
233
|
+
const number = Number.parseInt(digits, 10);
|
|
234
|
+
if (number < 1) return { unsupported: 'zero', marker };
|
|
235
|
+
return { ...base, candidates: [{ format: 'decimal', value: number }] };
|
|
236
|
+
}
|
|
237
|
+
const upper = letters === letters.toUpperCase();
|
|
238
|
+
const lower = letters === letters.toLowerCase();
|
|
239
|
+
if (!upper && !lower) return null;
|
|
240
|
+
const candidates = [];
|
|
241
|
+
if (letters.length === 1) {
|
|
242
|
+
candidates.push({ format: upper ? 'upperLetter' : 'lowerLetter', value: letters.toLowerCase().charCodeAt(0) - 96 });
|
|
243
|
+
}
|
|
244
|
+
const roman = romanValue(letters);
|
|
245
|
+
if (roman != null) candidates.push({ format: upper ? 'upperRoman' : 'lowerRoman', value: roman });
|
|
246
|
+
if (candidates.length === 0) return null;
|
|
247
|
+
return { ...base, candidates };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Chooses one list format for a run of parsed markers.
|
|
252
|
+
*
|
|
253
|
+
* With `requestedFormat`, every marker must offer it. Otherwise the formats every marker offers are
|
|
254
|
+
* intersected; several remaining formats (a lone `I.`, or `C.` / `D.`) are resolved only when exactly one of
|
|
255
|
+
* them makes the values a consecutive sequence (`C., D.` are letters 3, 4; roman 100, 500 are not). Returns
|
|
256
|
+
* `{ format }` or `{ error: { reason, ... } }`.
|
|
257
|
+
*/
|
|
258
|
+
export function resolveMarkerFormat(markers, requestedFormat = null) {
|
|
259
|
+
if (markers.length === 0) return { error: { reason: 'none' } };
|
|
260
|
+
const terminators = new Set(markers.map(item => item.terminator));
|
|
261
|
+
const bulletChars = new Set(markers.filter(item => item.bulletChar).map(item => item.bulletChar));
|
|
262
|
+
if (terminators.size > 1 || bulletChars.size > 1) {
|
|
263
|
+
return { error: { reason: 'mixed', markers: markers.map(item => item.marker) } };
|
|
264
|
+
}
|
|
265
|
+
if (requestedFormat) {
|
|
266
|
+
const mismatch = markers.find(item => !item.candidates.some(candidate => candidate.format === requestedFormat));
|
|
267
|
+
if (mismatch) return { error: { reason: 'format-mismatch', marker: mismatch.marker, requestedFormat } };
|
|
268
|
+
return { format: requestedFormat };
|
|
269
|
+
}
|
|
270
|
+
let shared = markers[0].candidates.map(candidate => candidate.format);
|
|
271
|
+
for (const item of markers.slice(1)) {
|
|
272
|
+
const formats = item.candidates.map(candidate => candidate.format);
|
|
273
|
+
shared = shared.filter(format => formats.includes(format));
|
|
274
|
+
}
|
|
275
|
+
if (shared.length === 0) return { error: { reason: 'mixed', markers: markers.map(item => item.marker) } };
|
|
276
|
+
if (shared.length === 1) return { format: shared[0] };
|
|
277
|
+
const consecutive = shared.filter(format => {
|
|
278
|
+
if (markers.length < 2) return false;
|
|
279
|
+
const values = markers.map(item => item.candidates.find(candidate => candidate.format === format).value);
|
|
280
|
+
return values.every((value, index) => index === 0 || value === values[index - 1] + 1);
|
|
281
|
+
});
|
|
282
|
+
if (consecutive.length === 1) return { format: consecutive[0] };
|
|
283
|
+
return { error: { reason: 'ambiguous', marker: markers[0].marker, formats: shared } };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The value a marker has under a resolved format (null for bullets). */
|
|
287
|
+
export function markerValue(parsed, format) {
|
|
288
|
+
return parsed.candidates.find(candidate => candidate.format === format)?.value ?? null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The `w:lvl` redefinition for `allocateNumberingDefinition`. The level text keeps the typed terminator, so
|
|
293
|
+
* the label Word renders after Accept All is the text the marker showed (`A.` stays `A.`, `1)` stays `1)`).
|
|
294
|
+
*/
|
|
295
|
+
export function listLevelSpec({ format, level, terminator = '.', bulletChar = null }) {
|
|
296
|
+
if (format === 'bullet') {
|
|
297
|
+
return { ilvl: level, numFmt: 'bullet', lvlText: bulletChar === '-' || bulletChar === '*' || !bulletChar ? '•' : bulletChar };
|
|
298
|
+
}
|
|
299
|
+
return { ilvl: level, numFmt: format, lvlText: `%${level + 1}${terminator || '.'}` };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Sequence-state key for continuing a converted list across operations of one batch. */
|
|
303
|
+
export function listFormatSequenceKey({ format, level, terminator = '', bulletChar = null }) {
|
|
304
|
+
return `list-format:${format}:${level}:${format === 'bullet' ? (bulletChar || '') : (terminator || '.')}`;
|
|
305
|
+
}
|