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