@ansonlai/docx-redline-js 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,419 +1,415 @@
1
- /**
2
- * List generation flow extracted from ReconciliationPipeline.
3
- */
4
-
5
- import { preprocessMarkdown } from './markdown-processor.js';
6
- import { matchListMarker, stripListMarker } from './list-markers.js';
7
- import { serializeToOoxml } from './serialization.js';
8
- import { generateTableOoxml } from '../services/table-reconciliation.js';
9
- import { parseTable } from './content-analysis.js';
10
- import { log } from '../adapters/logger.js';
11
- import { createRevisionMetadata, NS_W, RunKind } from '../core/types.js';
12
- import { parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
13
- import { createWordElement } from '../core/word-xml.js';
14
-
15
- /**
16
- * Executes list generation when paragraph content expands into list/table blocks.
17
- *
18
- * @param {Object} options - Generation options
19
- * @param {string} options.cleanText - Preprocessed target text
20
- * @param {Object|null} options.numberingContext - Existing numbering context
21
- * @param {Array} [options.originalRunModel=[]] - Original run model
22
- * @param {string} [options.originalText=''] - Original text fallback
23
- * @param {boolean} [options.generateRedlines=true] - Track-change toggle
24
- * @param {string} [options.author='AI'] - Author metadata
25
- * @param {string|null} [options.font=null] - Optional font
26
- * @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
27
- * @returns {Promise<import('../core/types.js').ReconciliationResult>}
28
- */
29
- export async function executeListGeneration(options) {
30
- const {
31
- cleanText,
32
- numberingContext,
33
- originalRunModel = [],
34
- originalText = '',
35
- generateRedlines = true,
36
- author = 'AI',
37
- font = null,
38
- revisionIdAllocator = null,
39
- numberingService
40
- } = options;
41
-
42
- const normalizedListText = normalizeCompositeListMarkers(cleanText);
43
- const lineMetadata = buildLineMetadata(normalizedListText);
44
- const rawLines = lineMetadata.map(line => line.raw);
45
- const results = [];
46
- const sourcePPr = getSourceParagraphProperties(originalRunModel);
47
- const inheritedTypographyRPrXml = extractInheritedTypographyRPrXml(originalRunModel, sourcePPr);
48
- const inheritedHeadingRPrXml = extractInheritedTypographyRPrXml(
49
- originalRunModel,
50
- sourcePPr,
51
- ['rFonts', 'kern', 'position', 'rtl', 'cs', 'lang']
52
- );
53
-
54
- let deletionRuns = [];
55
- if (generateRedlines) {
56
- if (originalRunModel && originalRunModel.length > 0) {
57
- deletionRuns = originalRunModel
58
- .filter(run => run.kind === 'text' || run.kind === 'run')
59
- .map(run => ({ ...run, kind: 'deletion', author }));
60
- } else if (originalText && originalText.trim().length > 0) {
61
- const trimmed = originalText.trim();
62
- deletionRuns = [{
63
- kind: 'deletion',
64
- text: trimmed,
65
- author,
66
- startOffset: 0,
67
- endOffset: trimmed.length
68
- }];
69
- }
70
- }
71
-
72
- const sourceParagraphCount = (originalRunModel || [])
73
- .filter(run => run.kind === RunKind.PARAGRAPH_START).length;
74
- if (generateRedlines && sourceParagraphCount > 0 && (sourceParagraphCount > 1 || deletionRuns.length === 0)) {
75
- // Each source paragraph keeps its own deleted paragraph (content and tracked-deleted mark), so
76
- // Reject All restores every original paragraph boundary instead of merging them into one.
77
- // Serialize each source paragraph on its own so empty paragraphs are emitted too (an empty
78
- // paragraph carrying only its tracked-deleted mark).
79
- let segment = null;
80
- const flushSegment = () => {
81
- if (!segment) return;
82
- results.push(serializeToOoxml(segment, null, [], {
83
- author,
84
- generateRedlines,
85
- revisionIdAllocator
86
- }));
87
- segment = null;
88
- };
89
- for (const run of originalRunModel) {
90
- if (run.kind === RunKind.PARAGRAPH_START) {
91
- flushSegment();
92
- segment = [{
93
- ...run,
94
- pPrElement: null,
95
- pPrXml: addParagraphMarkRevision(
96
- run.pPrElement || run.pPrXml || null,
97
- 'del',
98
- author,
99
- revisionIdAllocator
100
- )
101
- }];
102
- } else if (segment && (run.kind === 'text' || run.kind === 'run')) {
103
- segment.push({ ...run, kind: 'deletion', author });
104
- }
105
- }
106
- flushSegment();
107
- } else if (generateRedlines && deletionRuns.length > 0) {
108
- const deletedPPr = addParagraphMarkRevision(sourcePPr, 'del', author, revisionIdAllocator);
109
- const deletedParagraph = serializeToOoxml(deletionRuns, deletedPPr, [], {
110
- author,
111
- generateRedlines,
112
- revisionIdAllocator
113
- });
114
- results.push(deletedParagraph);
115
- }
116
-
117
- const indentStep = detectIndentationStep(rawLines);
118
- log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
119
-
120
- const firstMarker = lineMetadata.find(line => line.marker)?.marker || '';
121
- const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
122
- log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
123
-
124
- for (let i = 0; i < lineMetadata.length; i++) {
125
- const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
126
- if (tableBlock) {
127
- const tableData = parseTable(tableBlock.tableText);
128
- if (tableData.headers.length > 0 || tableData.rows.length > 0) {
129
- results.push(generateTableOoxml(tableData, {
130
- generateRedlines,
131
- author,
132
- revisionIdAllocator,
133
- trackAsBlock: true
134
- }));
135
- i = tableBlock.endIndex;
136
- continue;
137
- }
138
- }
139
-
140
- const line = lineMetadata[i];
141
- const entry = buildListEntry(
142
- line,
143
- indentStep,
144
- numberingContext,
145
- numberingService,
146
- generateRedlines,
147
- author,
148
- font,
149
- revisionIdAllocator,
150
- inheritedTypographyRPrXml,
151
- inheritedHeadingRPrXml
152
- );
153
- results.push(entry.ooxml);
154
- }
155
-
156
- const numberingXml = numberingService.generateNumberingXml();
157
- const finalOoxml = results.join('');
158
-
159
- log(`[ListGen] ✅ Generated OOXML for ${results.length} paragraphs, total length: ${finalOoxml.length}`);
160
- log(`[ListGen] First 200 chars: ${finalOoxml.substring(0, 200)}...`);
161
-
162
- return {
163
- ooxml: finalOoxml,
164
- isValid: true,
165
- warnings: ['Paragraph expanded to list fragment'],
166
- type: 'fragment',
167
- includeNumbering: true,
168
- numberingXml
169
- };
170
- }
171
-
172
- /**
173
- * Heuristically detects indentation step (spaces/tabs per level).
174
- *
175
- * @param {string[]} lines - Raw lines
176
- * @returns {number}
177
- */
178
- export function detectIndentationStep(lines) {
179
- const indentations = lines
180
- .map(line => line.match(/^(\s*)/)[0].length)
181
- .filter(length => length > 0)
182
- .sort((a, b) => a - b);
183
-
184
- if (indentations.length === 0) return 2;
185
-
186
- let minJump = indentations[0];
187
- for (let i = 1; i < indentations.length; i++) {
188
- const jump = indentations[i] - indentations[i - 1];
189
- if (jump > 0 && jump < minJump) {
190
- minJump = jump;
191
- }
192
- }
193
-
194
- return minJump || 2;
195
- }
196
-
197
- function buildLineMetadata(cleanText) {
198
- return cleanText
199
- .split('\n')
200
- .filter(line => line.trim().length > 0)
201
- .map(raw => {
202
- const markerMatch = matchListMarker(raw);
203
- const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
204
- return {
205
- raw,
206
- marker: markerMatch ? markerMatch[2].trim() : '',
207
- headerMatch,
208
- indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
209
- isTableLine: /^\s*\|/.test(raw),
210
- isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
211
- };
212
- });
213
- }
214
-
215
- function normalizeCompositeListMarkers(text) {
216
- const lines = String(text || '').split('\n');
217
- const nonEmptyIndexes = lines
218
- .map((line, index) => ({ line, index }))
219
- .filter(entry => entry.line.trim().length > 0);
220
- if (nonEmptyIndexes.length < 2) return text;
221
-
222
- const rewrites = [];
223
- let rewrittenCount = 0;
224
-
225
- for (const { line, index } of nonEmptyIndexes) {
226
- const outerMarkerMatch = matchListMarker(line);
227
- if (!outerMarkerMatch) return text;
228
-
229
- const stripped = stripListMarker(line);
230
- const innerMarkerMatch = matchListMarker(stripped);
231
- if (!innerMarkerMatch) return text;
232
-
233
- const outerMarker = (outerMarkerMatch[2] || '').trim();
234
- const innerMarker = (innerMarkerMatch[2] || '').trim();
235
- if (!outerMarker || !innerMarker || outerMarker === innerMarker) return text;
236
-
237
- const indent = outerMarkerMatch[1] || '';
238
- const rewritten = `${indent}${stripped.trimStart()}`;
239
- rewrites.push({ index, rewritten });
240
- rewrittenCount++;
241
- }
242
-
243
- if (rewrittenCount < 2) return text;
244
-
245
- const updated = lines.slice();
246
- for (const rewrite of rewrites) {
247
- updated[rewrite.index] = rewrite.rewritten;
248
- }
249
- log(`[ListGen] Normalized ${rewrittenCount} composite list markers (e.g., "- A." -> "A.").`);
250
- return updated.join('\n');
251
- }
252
-
253
- function collectMarkdownTableBlock(lineMetadata, index) {
254
- const current = lineMetadata[index];
255
- const next = lineMetadata[index + 1];
256
-
257
- if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
258
- return null;
259
- }
260
-
261
- const tableLines = [];
262
- let cursor = index;
263
- while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
264
- tableLines.push(lineMetadata[cursor].raw);
265
- cursor++;
266
- }
267
-
268
- return {
269
- tableText: tableLines.join('\n'),
270
- endIndex: cursor - 1
271
- };
272
- }
273
-
274
- function buildListEntry(
275
- line,
276
- indentStep,
277
- numberingContext,
278
- numberingService,
279
- generateRedlines,
280
- author,
281
- font,
282
- revisionIdAllocator,
283
- inheritedTypographyRPrXml,
284
- inheritedHeadingRPrXml
285
- ) {
286
- let pPrXml = '';
287
- let segmentText = '';
288
- let insertedRPrXml = inheritedTypographyRPrXml;
289
-
290
- if (line.headerMatch) {
291
- const level = Math.min(line.headerMatch[1].length, 9);
292
- const outlineLevel = Math.min(level - 1, 8);
293
- const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
294
- const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
295
- segmentText = line.headerMatch[2].trim();
296
- pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
297
- insertedRPrXml = mergeRunProperties(
298
- inheritedHeadingRPrXml,
299
- `<w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr>`
300
- );
301
- } else if (line.marker) {
302
- const lineFormat = numberingService.detectNumberingFormat(line.marker);
303
- const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
304
- const contextLevel = numberingContext?.ilvl || 0;
305
- const ilvl = lineFormat.format === 'outline'
306
- ? Math.min(8, lineFormat.depth)
307
- : Math.min(8, indentLevel + contextLevel);
308
-
309
- segmentText = stripListMarker(line.raw);
310
- const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
311
- pPrXml = numberingService.buildListPPr(numId, ilvl);
312
- } else {
313
- segmentText = line.raw;
314
- }
315
-
316
- if (generateRedlines) {
317
- pPrXml = addParagraphMarkRevision(
318
- pPrXml || '<w:pPr/>',
319
- 'ins',
320
- author,
321
- revisionIdAllocator
322
- );
323
- }
324
-
325
- const { cleanText, formatHints } = preprocessMarkdown(segmentText);
326
- const runModel = [];
327
-
328
- runModel.push({
329
- kind: generateRedlines ? 'insertion' : 'run',
330
- text: cleanText,
331
- rPrXml: insertedRPrXml,
332
- author,
333
- startOffset: 0,
334
- endOffset: cleanText.length
335
- });
336
-
337
- return {
338
- ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
339
- author,
340
- generateRedlines,
341
- font,
342
- revisionIdAllocator
343
- })
344
- };
345
- }
346
-
347
- function getSourceParagraphProperties(originalRunModel) {
348
- const paragraphStart = (originalRunModel || []).find(run => run.kind === RunKind.PARAGRAPH_START);
349
- return paragraphStart?.pPrElement || paragraphStart?.pPrXml || null;
350
- }
351
-
352
- function extractInheritedTypographyRPrXml(
353
- originalRunModel,
354
- sourcePPr,
355
- tagOrder = ['rFonts', 'kern', 'position', 'sz', 'szCs', 'rtl', 'cs', 'lang']
356
- ) {
357
- const sources = (originalRunModel || [])
358
- .filter(run => (run.kind === RunKind.TEXT || run.kind === 'text') && run.rPrXml)
359
- .map(run => run.rPrXml);
360
-
361
- if (sourcePPr) {
362
- sources.push(typeof sourcePPr === 'string' ? sourcePPr : serializeXml(sourcePPr));
363
- }
364
-
365
- const inherited = [];
366
- for (const tagName of tagOrder) {
367
- let match = null;
368
- for (const source of sources) {
369
- match = String(source || '').match(new RegExp(`<w:${tagName}\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/w:${tagName}>)`));
370
- if (match) break;
371
- }
372
- if (match) inherited.push(match[0]);
373
- }
374
-
375
- return inherited.length > 0 ? `<w:rPr>${inherited.join('')}</w:rPr>` : '';
376
- }
377
-
378
- function mergeRunProperties(...sources) {
379
- const properties = sources
380
- .map(source => String(source || '').replace(/^\s*<w:rPr[^>]*>|<\/w:rPr>\s*$/g, ''))
381
- .filter(Boolean)
382
- .join('');
383
- return properties ? `<w:rPr>${properties}</w:rPr>` : '';
384
- }
385
-
386
- function addParagraphMarkRevision(xml, type, author, revisionIdAllocator) {
387
- const metadata = createRevisionMetadata(author, revisionIdAllocator);
388
- let pPr;
389
- let ownerDoc;
390
-
391
- if (xml?.nodeType === 1) {
392
- pPr = xml.cloneNode(true);
393
- ownerDoc = pPr.ownerDocument;
394
- } else {
395
- const pPrXml = typeof xml === 'string' && xml.trim() ? xml : '<w:pPr/>';
396
- const parsed = parseOoxmlSafe(`<w:root xmlns:w="${NS_W}">${pPrXml}</w:root>`);
397
- if (!parsed.doc) {
398
- throw new Error(parsed.error?.message || 'Could not parse paragraph properties for list revision');
399
- }
400
- ownerDoc = parsed.doc;
401
- pPr = Array.from(ownerDoc.documentElement.childNodes || []).find(node => (
402
- node.nodeType === 1 && node.localName === 'pPr'
403
- ));
404
- }
405
-
406
- if (!pPr || !ownerDoc) throw new Error('List paragraph properties are unavailable');
407
- let rPr = Array.from(pPr.childNodes || []).find(node => node.nodeType === 1 && node.localName === 'rPr');
408
- if (!rPr) {
409
- rPr = createWordElement(ownerDoc, 'w:rPr');
410
- pPr.appendChild(rPr);
411
- }
412
-
413
- const marker = createWordElement(ownerDoc, type === 'del' ? 'w:del' : 'w:ins');
414
- marker.setAttribute('w:id', String(metadata.id));
415
- marker.setAttribute('w:author', metadata.author);
416
- marker.setAttribute('w:date', metadata.date);
417
- rPr.appendChild(marker);
418
- return serializeXml(pPr);
419
- }
1
+ /**
2
+ * List generation flow extracted from ReconciliationPipeline.
3
+ */
4
+
5
+ import { preprocessMarkdown } from './markdown-processor.js';
6
+ import { matchListMarker, stripListMarker } from './list-markers.js';
7
+ import { serializeToOoxml } from './serialization.js';
8
+ import { generateTableOoxml } from '../services/table-reconciliation.js';
9
+ import { parseTable } from './content-analysis.js';
10
+ import { log } from '../adapters/logger.js';
11
+ import { NS_W, RunKind } from '../core/types.js';
12
+ import { parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
13
+ import { setParagraphPropertiesMarkRevision } from '../engine/paragraph-mark-revisions.js';
14
+
15
+ /**
16
+ * Executes list generation when paragraph content expands into list/table blocks.
17
+ *
18
+ * @param {Object} options - Generation options
19
+ * @param {string} options.cleanText - Preprocessed target text
20
+ * @param {Object|null} options.numberingContext - Existing numbering context
21
+ * @param {Array} [options.originalRunModel=[]] - Original run model
22
+ * @param {string} [options.originalText=''] - Original text fallback
23
+ * @param {boolean} [options.generateRedlines=true] - Track-change toggle
24
+ * @param {string} [options.author='AI'] - Author metadata
25
+ * @param {string|null} [options.font=null] - Optional font
26
+ * @param {import('../services/numbering-service.js').NumberingService} options.numberingService - Numbering service
27
+ * @returns {Promise<import('../core/types.js').ReconciliationResult>}
28
+ */
29
+ export async function executeListGeneration(options) {
30
+ const {
31
+ cleanText,
32
+ numberingContext,
33
+ originalRunModel = [],
34
+ originalText = '',
35
+ generateRedlines = true,
36
+ author = 'AI',
37
+ font = null,
38
+ revisionIdAllocator = null,
39
+ numberingService
40
+ } = options;
41
+
42
+ const normalizedListText = normalizeCompositeListMarkers(cleanText);
43
+ const lineMetadata = buildLineMetadata(normalizedListText);
44
+ const rawLines = lineMetadata.map(line => line.raw);
45
+ const results = [];
46
+ const sourcePPr = getSourceParagraphProperties(originalRunModel);
47
+ const inheritedTypographyRPrXml = extractInheritedTypographyRPrXml(originalRunModel, sourcePPr);
48
+ const inheritedHeadingRPrXml = extractInheritedTypographyRPrXml(
49
+ originalRunModel,
50
+ sourcePPr,
51
+ ['rFonts', 'kern', 'position', 'rtl', 'cs', 'lang']
52
+ );
53
+
54
+ let deletionRuns = [];
55
+ if (generateRedlines) {
56
+ if (originalRunModel && originalRunModel.length > 0) {
57
+ deletionRuns = originalRunModel
58
+ .filter(run => run.kind === 'text' || run.kind === 'run'
59
+ || run.kind === RunKind.CONTAINER_START || run.kind === RunKind.CONTAINER_END)
60
+ .map(run => (run.kind === RunKind.CONTAINER_START || run.kind === RunKind.CONTAINER_END
61
+ ? run
62
+ : { ...run, kind: 'deletion', author }));
63
+ } else if (originalText && originalText.trim().length > 0) {
64
+ const trimmed = originalText.trim();
65
+ deletionRuns = [{
66
+ kind: 'deletion',
67
+ text: trimmed,
68
+ author,
69
+ startOffset: 0,
70
+ endOffset: trimmed.length
71
+ }];
72
+ }
73
+ }
74
+
75
+ const sourceParagraphCount = (originalRunModel || [])
76
+ .filter(run => run.kind === RunKind.PARAGRAPH_START).length;
77
+ if (generateRedlines && sourceParagraphCount > 0 && (sourceParagraphCount > 1 || deletionRuns.length === 0)) {
78
+ // Each source paragraph keeps its own deleted paragraph (content and tracked-deleted mark), so
79
+ // Reject All restores every original paragraph boundary instead of merging them into one.
80
+ // Serialize each source paragraph on its own so empty paragraphs are emitted too (an empty
81
+ // paragraph carrying only its tracked-deleted mark).
82
+ let segment = null;
83
+ const flushSegment = () => {
84
+ if (!segment) return;
85
+ results.push(serializeToOoxml(segment, null, [], {
86
+ author,
87
+ generateRedlines,
88
+ revisionIdAllocator
89
+ }));
90
+ segment = null;
91
+ };
92
+ for (const run of originalRunModel) {
93
+ if (run.kind === RunKind.PARAGRAPH_START) {
94
+ flushSegment();
95
+ segment = [{
96
+ ...run,
97
+ pPrElement: null,
98
+ pPrXml: addParagraphMarkRevision(
99
+ run.pPrElement || run.pPrXml || null,
100
+ 'del',
101
+ author,
102
+ revisionIdAllocator
103
+ )
104
+ }];
105
+ } else if (segment && (run.kind === 'text' || run.kind === 'run')) {
106
+ segment.push({ ...run, kind: 'deletion', author });
107
+ } else if (segment && (run.kind === RunKind.CONTAINER_START || run.kind === RunKind.CONTAINER_END)) {
108
+ // Keep the hyperlink / content-control wrapper around the deleted runs so Reject All restores
109
+ // the text inside it, not as loose runs next to it.
110
+ segment.push(run);
111
+ }
112
+ }
113
+ flushSegment();
114
+ } else if (generateRedlines && deletionRuns.length > 0) {
115
+ const deletedPPr = addParagraphMarkRevision(sourcePPr, 'del', author, revisionIdAllocator);
116
+ const deletedParagraph = serializeToOoxml(deletionRuns, deletedPPr, [], {
117
+ author,
118
+ generateRedlines,
119
+ revisionIdAllocator
120
+ });
121
+ results.push(deletedParagraph);
122
+ }
123
+
124
+ const indentStep = detectIndentationStep(rawLines);
125
+ log(`[ListGen] Detected indentation step: ${indentStep} spaces/chars`);
126
+
127
+ const firstMarker = lineMetadata.find(line => line.marker)?.marker || '';
128
+ const { format: defaultFormat } = numberingService.detectNumberingFormat(firstMarker);
129
+ log(`[ListGen] Detected primary marker: "${firstMarker}", format: ${defaultFormat}`);
130
+
131
+ for (let i = 0; i < lineMetadata.length; i++) {
132
+ const tableBlock = collectMarkdownTableBlock(lineMetadata, i);
133
+ if (tableBlock) {
134
+ const tableData = parseTable(tableBlock.tableText);
135
+ if (tableData.headers.length > 0 || tableData.rows.length > 0) {
136
+ results.push(generateTableOoxml(tableData, {
137
+ generateRedlines,
138
+ author,
139
+ revisionIdAllocator,
140
+ trackAsBlock: true
141
+ }));
142
+ i = tableBlock.endIndex;
143
+ continue;
144
+ }
145
+ }
146
+
147
+ const line = lineMetadata[i];
148
+ const entry = buildListEntry(
149
+ line,
150
+ indentStep,
151
+ numberingContext,
152
+ numberingService,
153
+ generateRedlines,
154
+ author,
155
+ font,
156
+ revisionIdAllocator,
157
+ inheritedTypographyRPrXml,
158
+ inheritedHeadingRPrXml
159
+ );
160
+ results.push(entry.ooxml);
161
+ }
162
+
163
+ const numberingXml = numberingService.generateNumberingXml();
164
+ const finalOoxml = results.join('');
165
+
166
+ log(`[ListGen] ✅ Generated OOXML for ${results.length} paragraphs, total length: ${finalOoxml.length}`);
167
+ log(`[ListGen] First 200 chars: ${finalOoxml.substring(0, 200)}...`);
168
+
169
+ return {
170
+ ooxml: finalOoxml,
171
+ isValid: true,
172
+ warnings: ['Paragraph expanded to list fragment'],
173
+ type: 'fragment',
174
+ includeNumbering: true,
175
+ numberingXml
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Heuristically detects indentation step (spaces/tabs per level).
181
+ *
182
+ * @param {string[]} lines - Raw lines
183
+ * @returns {number}
184
+ */
185
+ export function detectIndentationStep(lines) {
186
+ const indentations = lines
187
+ .map(line => line.match(/^(\s*)/)[0].length)
188
+ .filter(length => length > 0)
189
+ .sort((a, b) => a - b);
190
+
191
+ if (indentations.length === 0) return 2;
192
+
193
+ let minJump = indentations[0];
194
+ for (let i = 1; i < indentations.length; i++) {
195
+ const jump = indentations[i] - indentations[i - 1];
196
+ if (jump > 0 && jump < minJump) {
197
+ minJump = jump;
198
+ }
199
+ }
200
+
201
+ return minJump || 2;
202
+ }
203
+
204
+ function buildLineMetadata(cleanText) {
205
+ return cleanText
206
+ .split('\n')
207
+ .filter(line => line.trim().length > 0)
208
+ .map(raw => {
209
+ const markerMatch = matchListMarker(raw);
210
+ const headerMatch = raw.match(/^\s*(#{1,9})\s+(.*)/);
211
+ return {
212
+ raw,
213
+ marker: markerMatch ? markerMatch[2].trim() : '',
214
+ headerMatch,
215
+ indentSize: (raw.match(/^(\s*)/)?.[1].length) || 0,
216
+ isTableLine: /^\s*\|/.test(raw),
217
+ isTableSeparator: /^\s*\|?[\s:-]*-[-\s|:]*\|?\s*$/.test(raw)
218
+ };
219
+ });
220
+ }
221
+
222
+ function normalizeCompositeListMarkers(text) {
223
+ const lines = String(text || '').split('\n');
224
+ const nonEmptyIndexes = lines
225
+ .map((line, index) => ({ line, index }))
226
+ .filter(entry => entry.line.trim().length > 0);
227
+ if (nonEmptyIndexes.length < 2) return text;
228
+
229
+ const rewrites = [];
230
+ let rewrittenCount = 0;
231
+
232
+ for (const { line, index } of nonEmptyIndexes) {
233
+ const outerMarkerMatch = matchListMarker(line);
234
+ if (!outerMarkerMatch) return text;
235
+
236
+ const stripped = stripListMarker(line);
237
+ const innerMarkerMatch = matchListMarker(stripped);
238
+ if (!innerMarkerMatch) return text;
239
+
240
+ const outerMarker = (outerMarkerMatch[2] || '').trim();
241
+ const innerMarker = (innerMarkerMatch[2] || '').trim();
242
+ if (!outerMarker || !innerMarker || outerMarker === innerMarker) return text;
243
+
244
+ const indent = outerMarkerMatch[1] || '';
245
+ const rewritten = `${indent}${stripped.trimStart()}`;
246
+ rewrites.push({ index, rewritten });
247
+ rewrittenCount++;
248
+ }
249
+
250
+ if (rewrittenCount < 2) return text;
251
+
252
+ const updated = lines.slice();
253
+ for (const rewrite of rewrites) {
254
+ updated[rewrite.index] = rewrite.rewritten;
255
+ }
256
+ log(`[ListGen] Normalized ${rewrittenCount} composite list markers (e.g., "- A." -> "A.").`);
257
+ return updated.join('\n');
258
+ }
259
+
260
+ function collectMarkdownTableBlock(lineMetadata, index) {
261
+ const current = lineMetadata[index];
262
+ const next = lineMetadata[index + 1];
263
+
264
+ if (!current?.isTableLine || !next?.isTableLine || !next?.isTableSeparator) {
265
+ return null;
266
+ }
267
+
268
+ const tableLines = [];
269
+ let cursor = index;
270
+ while (cursor < lineMetadata.length && lineMetadata[cursor].isTableLine) {
271
+ tableLines.push(lineMetadata[cursor].raw);
272
+ cursor++;
273
+ }
274
+
275
+ return {
276
+ tableText: tableLines.join('\n'),
277
+ endIndex: cursor - 1
278
+ };
279
+ }
280
+
281
+ function buildListEntry(
282
+ line,
283
+ indentStep,
284
+ numberingContext,
285
+ numberingService,
286
+ generateRedlines,
287
+ author,
288
+ font,
289
+ revisionIdAllocator,
290
+ inheritedTypographyRPrXml,
291
+ inheritedHeadingRPrXml
292
+ ) {
293
+ let pPrXml = '';
294
+ let segmentText = '';
295
+ let insertedRPrXml = inheritedTypographyRPrXml;
296
+
297
+ if (line.headerMatch) {
298
+ const level = Math.min(line.headerMatch[1].length, 9);
299
+ const outlineLevel = Math.min(level - 1, 8);
300
+ const headingSizes = [32, 28, 26, 24, 22, 20, 20, 20, 20];
301
+ const headingSize = headingSizes[level - 1] || headingSizes[headingSizes.length - 1];
302
+ segmentText = line.headerMatch[2].trim();
303
+ pPrXml = `<w:pPr><w:pStyle w:val="Heading${level}"/><w:outlineLvl w:val="${outlineLevel}"/><w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr></w:pPr>`;
304
+ insertedRPrXml = mergeRunProperties(
305
+ inheritedHeadingRPrXml,
306
+ `<w:rPr><w:b/><w:sz w:val="${headingSize}"/><w:szCs w:val="${headingSize}"/></w:rPr>`
307
+ );
308
+ } else if (line.marker) {
309
+ const lineFormat = numberingService.detectNumberingFormat(line.marker);
310
+ const indentLevel = indentStep > 0 ? Math.floor(line.indentSize / indentStep) : 0;
311
+ const contextLevel = numberingContext?.ilvl || 0;
312
+ const ilvl = lineFormat.format === 'outline'
313
+ ? Math.min(8, lineFormat.depth)
314
+ : Math.min(8, indentLevel + contextLevel);
315
+
316
+ segmentText = stripListMarker(line.raw);
317
+ const numId = numberingService.getOrCreateNumId({ type: lineFormat.format }, numberingContext);
318
+ pPrXml = numberingService.buildListPPr(numId, ilvl);
319
+ } else {
320
+ segmentText = line.raw;
321
+ }
322
+
323
+ if (generateRedlines) {
324
+ pPrXml = addParagraphMarkRevision(
325
+ pPrXml || '<w:pPr/>',
326
+ 'ins',
327
+ author,
328
+ revisionIdAllocator
329
+ );
330
+ }
331
+
332
+ const { cleanText, formatHints } = preprocessMarkdown(segmentText);
333
+ const runModel = [];
334
+
335
+ runModel.push({
336
+ kind: generateRedlines ? 'insertion' : 'run',
337
+ text: cleanText,
338
+ rPrXml: insertedRPrXml,
339
+ author,
340
+ startOffset: 0,
341
+ endOffset: cleanText.length
342
+ });
343
+
344
+ return {
345
+ ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
346
+ author,
347
+ generateRedlines,
348
+ font,
349
+ revisionIdAllocator
350
+ })
351
+ };
352
+ }
353
+
354
+ function getSourceParagraphProperties(originalRunModel) {
355
+ const paragraphStart = (originalRunModel || []).find(run => run.kind === RunKind.PARAGRAPH_START);
356
+ return paragraphStart?.pPrElement || paragraphStart?.pPrXml || null;
357
+ }
358
+
359
+ function extractInheritedTypographyRPrXml(
360
+ originalRunModel,
361
+ sourcePPr,
362
+ tagOrder = ['rFonts', 'kern', 'position', 'sz', 'szCs', 'rtl', 'cs', 'lang']
363
+ ) {
364
+ const sources = (originalRunModel || [])
365
+ .filter(run => (run.kind === RunKind.TEXT || run.kind === 'text') && run.rPrXml)
366
+ .map(run => run.rPrXml);
367
+
368
+ if (sourcePPr) {
369
+ sources.push(typeof sourcePPr === 'string' ? sourcePPr : serializeXml(sourcePPr));
370
+ }
371
+
372
+ const inherited = [];
373
+ for (const tagName of tagOrder) {
374
+ let match = null;
375
+ for (const source of sources) {
376
+ match = String(source || '').match(new RegExp(`<w:${tagName}\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/w:${tagName}>)`));
377
+ if (match) break;
378
+ }
379
+ if (match) inherited.push(match[0]);
380
+ }
381
+
382
+ return inherited.length > 0 ? `<w:rPr>${inherited.join('')}</w:rPr>` : '';
383
+ }
384
+
385
+ function mergeRunProperties(...sources) {
386
+ const properties = sources
387
+ .map(source => String(source || '').replace(/^\s*<w:rPr[^>]*>|<\/w:rPr>\s*$/g, ''))
388
+ .filter(Boolean)
389
+ .join('');
390
+ return properties ? `<w:rPr>${properties}</w:rPr>` : '';
391
+ }
392
+
393
+ function addParagraphMarkRevision(xml, type, author, revisionIdAllocator) {
394
+ let pPr;
395
+ let ownerDoc;
396
+
397
+ if (xml?.nodeType === 1) {
398
+ pPr = xml.cloneNode(true);
399
+ ownerDoc = pPr.ownerDocument;
400
+ } else {
401
+ const pPrXml = typeof xml === 'string' && xml.trim() ? xml : '<w:pPr/>';
402
+ const parsed = parseOoxmlSafe(`<w:root xmlns:w="${NS_W}">${pPrXml}</w:root>`);
403
+ if (!parsed.doc) {
404
+ throw new Error(parsed.error?.message || 'Could not parse paragraph properties for list revision');
405
+ }
406
+ ownerDoc = parsed.doc;
407
+ pPr = Array.from(ownerDoc.documentElement.childNodes || []).find(node => (
408
+ node.nodeType === 1 && node.localName === 'pPr'
409
+ ));
410
+ }
411
+
412
+ if (!pPr || !ownerDoc) throw new Error('List paragraph properties are unavailable');
413
+ setParagraphPropertiesMarkRevision(pPr, type === 'del' ? 'del' : 'ins', { author, revisionIdAllocator });
414
+ return serializeXml(pPr);
415
+ }