@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.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  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/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  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 +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  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/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  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 +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -35,7 +35,10 @@ const MARKDOWN_PATTERNS = [
35
35
  { regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
36
36
  // Bold: **text** or __text__
37
37
  { regex: /\*\*(.+?)\*\*/, format: { bold: true } },
38
- { regex: /__(.+?)__/, format: { bold: true } },
38
+ // Underscore delimiters follow CommonMark flanking rules: not intraword, not part of a
39
+ // longer underscore run, and never around whitespace. So signature lines ("_____")
40
+ // and snake_case identifiers stay literal text.
41
+ { regex: /(?<![A-Za-z0-9_])__(?=[^_\s])(.+?)(?<=[^_\s])__(?![A-Za-z0-9_])/, format: { bold: true } },
39
42
  // Underline: ++text++
40
43
  { regex: /\+\+(.+?)\+\+/, format: { underline: true } },
41
44
  // Strikethrough: ~~text~~ or ~text~
@@ -43,7 +46,7 @@ const MARKDOWN_PATTERNS = [
43
46
  { regex: /~(.+?)~/, format: { strikethrough: true } },
44
47
  // Italic: *text* or _text_ (using lookahead only for compatibility)
45
48
  { regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
46
- { regex: /_(?!_)(.+?)_(?!_)/, format: { italic: true } }
49
+ { regex: /(?<![A-Za-z0-9_])_(?=[^_\s])(.+?)(?<=[^_\s])_(?![A-Za-z0-9_])/, format: { italic: true } }
47
50
  ];
48
51
 
49
52
  /**
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { ingestOoxml } from './ingestion.js';
8
8
  import { preprocessMarkdown } from './markdown-processor.js';
9
- import { isListTargetLoose, isListTargetStrict } from './list-markers.js';
9
+ import { isListTargetLoose, isListTargetStrict } from './list-markers.js';
10
10
  import { computeWordLevelDiffOps } from './diff-engine.js';
11
11
  import { splitRunsAtDiffBoundaries, applyPatches } from './patching.js';
12
12
  import { serializeToOoxml, wrapInDocumentFragment } from './serialization.js';
@@ -15,26 +15,26 @@ import { NumberingService } from '../services/numbering-service.js';
15
15
  import { detectNumberingContext } from './ingestion.js';
16
16
  import { generateTableOoxml } from '../services/table-reconciliation.js';
17
17
  import { executeListGeneration, detectIndentationStep } from './list-generation.js';
18
- import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
19
- import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
20
- import { log, error as logError } from '../adapters/logger.js';
21
- import { getFirstElementByTagNS, getXmlParseError } from '../core/xml-query.js';
22
- import { getPlatform } from '../adapters/config.js';
23
-
24
- const WEB_PLATFORM_NAMES = new Set(['officeonline', 'officeweb', 'web']);
25
-
26
- function isWebPlatform(platform) {
27
- if (!platform) return false;
28
- return WEB_PLATFORM_NAMES.has(String(platform).toLowerCase());
29
- }
30
-
31
- function isProductionBuild() {
32
- return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
33
- }
34
-
35
- function yieldToEventLoop() {
36
- return new Promise(resolve => setTimeout(resolve, 0));
37
- }
18
+ import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
19
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
20
+ import { log, error as logError } from '../adapters/logger.js';
21
+ import { getFirstElementByTagNS, getXmlParseError } from '../core/xml-query.js';
22
+ import { getPlatform } from '../adapters/config.js';
23
+
24
+ const WEB_PLATFORM_NAMES = new Set(['officeonline', 'officeweb', 'web']);
25
+
26
+ function isWebPlatform(platform) {
27
+ if (!platform) return false;
28
+ return WEB_PLATFORM_NAMES.has(String(platform).toLowerCase());
29
+ }
30
+
31
+ function isProductionBuild() {
32
+ return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
33
+ }
34
+
35
+ function yieldToEventLoop() {
36
+ return new Promise(resolve => setTimeout(resolve, 0));
37
+ }
38
38
 
39
39
  /**
40
40
  * Main reconciliation pipeline class.
@@ -47,114 +47,114 @@ export class ReconciliationPipeline {
47
47
  * @param {string} [options.author='AI'] - Author for track changes
48
48
  * @param {boolean} [options.validateOutput=true] - Validate output before returning
49
49
  */
50
- constructor(options = {}) {
51
- this.generateRedlines = options.generateRedlines ?? true;
52
- this.author = options.author ?? 'AI';
53
- this.validateOutput = options.validateOutput ?? true;
54
- this.validationMode = options.validationMode ?? 'auto';
55
- this.numberingService = options.numberingService || new NumberingService();
56
- this.font = options.font || null;
57
- this.revisionIdAllocator = options.revisionIdAllocator || null;
58
- this.platform = options.platform ?? getPlatform();
59
- this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
60
- this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
61
- this.yieldRunThreshold = options.yieldRunThreshold ?? 50;
62
- this.yieldCharThreshold = options.yieldCharThreshold ?? 5000;
63
- this.disableSemanticCleanupOverChars = options.disableSemanticCleanupOverChars ?? (this.isWebPlatform ? 8000 : Number.POSITIVE_INFINITY);
64
- }
50
+ constructor(options = {}) {
51
+ this.generateRedlines = options.generateRedlines ?? true;
52
+ this.author = options.author ?? 'AI';
53
+ this.validateOutput = options.validateOutput ?? true;
54
+ this.validationMode = options.validationMode ?? 'auto';
55
+ this.numberingService = options.numberingService || new NumberingService();
56
+ this.font = options.font || null;
57
+ this.revisionIdAllocator = options.revisionIdAllocator || null;
58
+ this.platform = options.platform ?? getPlatform();
59
+ this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
60
+ this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
61
+ this.yieldRunThreshold = options.yieldRunThreshold ?? 50;
62
+ this.yieldCharThreshold = options.yieldCharThreshold ?? 5000;
63
+ this.disableSemanticCleanupOverChars = options.disableSemanticCleanupOverChars ?? (this.isWebPlatform ? 8000 : Number.POSITIVE_INFINITY);
64
+ }
65
65
 
66
66
  /**
67
- * Executes the reconciliation pipeline.
68
- *
69
- * @param {string} originalOoxml - Original OOXML paragraph content
70
- * @param {string} newText - New text with optional markdown formatting
71
- * @param {{ xmlDoc?: Document|null }} [options={}] - Optional execution options
72
- * @returns {Promise<import('../core/types.js').ReconciliationResult>}
73
- */
74
- async execute(originalOoxml, newText, options = {}) {
75
- const warnings = [];
76
-
77
- try {
78
- // Stage 1: Ingest OOXML
79
- const parsed = options.xmlDoc
80
- ? { doc: options.xmlDoc, error: null, warnings: [] }
81
- : parseOoxmlSafe(originalOoxml, 'application/xml');
82
- if (parsed.error || !parsed.doc) {
83
- return {
84
- ooxml: originalOoxml,
85
- isValid: false,
86
- status: 'error',
87
- error: parsed.error,
88
- warnings: parsed.warnings || []
89
- };
90
- }
91
- warnings.push(...(parsed.warnings || []));
92
- const doc = parsed.doc;
93
- const pElement = getFirstElementByTagNS(doc, '*', 'p');
94
-
95
- const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
96
- const numberingContext = pElement ? detectNumberingContext(pElement) : null;
97
-
98
- log(`[Reconcile] Ingested ${runModel.length} runs, ${acceptedText.length} chars, numbering:`, numberingContext);
99
- await this.maybeYield(runModel.length, Math.max(acceptedText.length, newText?.length || 0));
100
-
101
- // Stage 2: Preprocess markdown
102
- const { cleanText, formatHints } = preprocessMarkdown(newText);
103
- log(`[Reconcile] Preprocessed: ${formatHints.length} format hints`);
104
- await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
105
-
106
- // Detect list-target content before any no-op short-circuit.
107
- // Structural conversion may still be required even when text is identical
108
- // (for example plain "A./B./C." lines -> true Word numbered list paragraphs).
109
- const isTargetListStrict = isListTargetStrict(cleanText);
110
- const isTargetListLoose = isListTargetLoose(cleanText);
111
- const isTargetList = isTargetListStrict || isTargetListLoose;
112
- if (!isTargetListStrict && isTargetListLoose) {
113
- log('[Reconcile] List-target detected via loose marker parsing; bypassing no-op short-circuit for structural conversion.');
114
- }
115
-
116
- // Early exit if no change
117
- if (acceptedText === cleanText && formatHints.length === 0 && !isTargetList) {
118
- log('[Reconcile] No changes detected');
119
- return {
120
- ooxml: originalOoxml,
121
- isValid: true,
122
- warnings: ['No changes detected']
67
+ * Executes the reconciliation pipeline.
68
+ *
69
+ * @param {string} originalOoxml - Original OOXML paragraph content
70
+ * @param {string} newText - New text with optional markdown formatting
71
+ * @param {{ xmlDoc?: Document|null }} [options={}] - Optional execution options
72
+ * @returns {Promise<import('../core/types.js').ReconciliationResult>}
73
+ */
74
+ async execute(originalOoxml, newText, options = {}) {
75
+ const warnings = [];
76
+
77
+ try {
78
+ // Stage 1: Ingest OOXML
79
+ const parsed = options.xmlDoc
80
+ ? { doc: options.xmlDoc, error: null, warnings: [] }
81
+ : parseOoxmlSafe(originalOoxml, 'application/xml');
82
+ if (parsed.error || !parsed.doc) {
83
+ return {
84
+ ooxml: originalOoxml,
85
+ isValid: false,
86
+ status: 'error',
87
+ error: parsed.error,
88
+ warnings: parsed.warnings || []
123
89
  };
124
- }
125
-
126
- // Stage 3: Compute word-level diff
127
- const shouldCleanupSemantic = Math.max(acceptedText.length, cleanText.length) < this.disableSemanticCleanupOverChars;
128
- const diffOps = computeWordLevelDiffOps(acceptedText, cleanText, {
129
- cleanupSemantic: shouldCleanupSemantic
130
- });
131
- if (!shouldCleanupSemantic) {
132
- log('[Reconcile] Skipping semantic diff cleanup for large web payload');
133
- }
134
- await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
135
-
136
- // Count actual paragraph elements ingested
137
- const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
138
- const sourceIsMarkedMultilineList = isListTargetStrict(acceptedText) || isListTargetLoose(acceptedText);
139
- const canPatchExistingMarkedList = isTargetList
140
- && sourceIsMarkedMultilineList
141
- && paragraphCount > 1
142
- && acceptedText !== cleanText;
143
-
144
- log(`[Reconcile] isTargetList: ${isTargetList}, paragraphCount: ${paragraphCount}`);
145
-
146
- // Preserve already-marked multi-paragraph lists during ordinary text edits.
147
- // Rebuilding the whole block would discard unchanged run formatting and
148
- // place all source deletion runs in the first generated paragraph, which
149
- // makes rejection merge the original paragraph boundaries.
150
- if (canPatchExistingMarkedList) {
151
- log('[Reconcile] Existing marked list edit detected; using run-aware patching to preserve formatting and paragraph boundaries.');
152
- } else if (isTargetList) {
153
- log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
154
- log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
155
- log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
156
- return this.executeListGeneration(cleanText, numberingContext, runModel);
157
- }
90
+ }
91
+ warnings.push(...(parsed.warnings || []));
92
+ const doc = parsed.doc;
93
+ const pElement = getFirstElementByTagNS(doc, '*', 'p');
94
+
95
+ const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
96
+ const numberingContext = pElement ? detectNumberingContext(pElement) : null;
97
+
98
+ log(`[Reconcile] Ingested ${runModel.length} runs, ${acceptedText.length} chars, numbering:`, numberingContext);
99
+ await this.maybeYield(runModel.length, Math.max(acceptedText.length, newText?.length || 0));
100
+
101
+ // Stage 2: Preprocess markdown
102
+ const { cleanText, formatHints } = preprocessMarkdown(newText);
103
+ log(`[Reconcile] Preprocessed: ${formatHints.length} format hints`);
104
+ await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
105
+
106
+ // Detect list-target content before any no-op short-circuit.
107
+ // Structural conversion may still be required even when text is identical
108
+ // (for example plain "A./B./C." lines -> true Word numbered list paragraphs).
109
+ const isTargetListStrict = isListTargetStrict(cleanText);
110
+ const isTargetListLoose = isListTargetLoose(cleanText);
111
+ const isTargetList = isTargetListStrict || isTargetListLoose;
112
+ if (!isTargetListStrict && isTargetListLoose) {
113
+ log('[Reconcile] List-target detected via loose marker parsing; bypassing no-op short-circuit for structural conversion.');
114
+ }
115
+
116
+ // Early exit if no change
117
+ if (acceptedText === cleanText && formatHints.length === 0 && !isTargetList) {
118
+ log('[Reconcile] No changes detected');
119
+ return {
120
+ ooxml: originalOoxml,
121
+ isValid: true,
122
+ warnings: ['No changes detected']
123
+ };
124
+ }
125
+
126
+ // Stage 3: Compute word-level diff
127
+ const shouldCleanupSemantic = Math.max(acceptedText.length, cleanText.length) < this.disableSemanticCleanupOverChars;
128
+ const diffOps = computeWordLevelDiffOps(acceptedText, cleanText, {
129
+ cleanupSemantic: shouldCleanupSemantic
130
+ });
131
+ if (!shouldCleanupSemantic) {
132
+ log('[Reconcile] Skipping semantic diff cleanup for large web payload');
133
+ }
134
+ await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
135
+
136
+ // Count actual paragraph elements ingested
137
+ const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
138
+ const sourceIsMarkedMultilineList = isListTargetStrict(acceptedText) || isListTargetLoose(acceptedText);
139
+ const canPatchExistingMarkedList = isTargetList
140
+ && sourceIsMarkedMultilineList
141
+ && paragraphCount > 1
142
+ && acceptedText !== cleanText;
143
+
144
+ log(`[Reconcile] isTargetList: ${isTargetList}, paragraphCount: ${paragraphCount}`);
145
+
146
+ // Preserve already-marked multi-paragraph lists during ordinary text edits.
147
+ // Rebuilding the whole block would discard unchanged run formatting and
148
+ // place all source deletion runs in the first generated paragraph, which
149
+ // makes rejection merge the original paragraph boundaries.
150
+ if (canPatchExistingMarkedList) {
151
+ log('[Reconcile] Existing marked list edit detected; using run-aware patching to preserve formatting and paragraph boundaries.');
152
+ } else if (isTargetList) {
153
+ log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
154
+ log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
155
+ log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
156
+ return this.executeListGeneration(cleanText, numberingContext, runModel);
157
+ }
158
158
 
159
159
  log(`[Reconcile] Computed ${diffOps.length} diff operations`);
160
160
 
@@ -163,29 +163,29 @@ export class ReconciliationPipeline {
163
163
  log(`[Reconcile] Split into ${splitModel.length} runs`);
164
164
 
165
165
  // Stage 5: Apply patches
166
- const patchedModel = applyPatches(splitModel, diffOps, {
167
- generateRedlines: this.generateRedlines,
168
- author: this.author,
169
- formatHints,
170
- numberingService: this.numberingService
171
- });
172
- log(`[Reconcile] Patched model has ${patchedModel.length} runs`);
173
- await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
166
+ const patchedModel = applyPatches(splitModel, diffOps, {
167
+ generateRedlines: this.generateRedlines,
168
+ author: this.author,
169
+ formatHints,
170
+ numberingService: this.numberingService
171
+ });
172
+ log(`[Reconcile] Patched model has ${patchedModel.length} runs`);
173
+ await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
174
174
 
175
175
  // Stage 6: Serialize to OOXML
176
- const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
177
- author: this.author,
178
- generateRedlines: this.generateRedlines,
179
- revisionIdAllocator: this.revisionIdAllocator
176
+ const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
177
+ author: this.author,
178
+ generateRedlines: this.generateRedlines,
179
+ revisionIdAllocator: this.revisionIdAllocator
180
180
  });
181
181
 
182
- // Stage 7: Basic validation
183
- if (this.shouldRunValidation()) {
184
- const validation = this.validateBasic(resultOoxml);
185
- if (!validation.isValid) {
186
- warnings.push(...validation.errors);
187
- }
188
- }
182
+ // Stage 7: Basic validation
183
+ if (this.shouldRunValidation()) {
184
+ const validation = this.validateBasic(resultOoxml);
185
+ if (!validation.isValid) {
186
+ warnings.push(...validation.errors);
187
+ }
188
+ }
189
189
 
190
190
  return {
191
191
  ooxml: resultOoxml,
@@ -193,15 +193,15 @@ export class ReconciliationPipeline {
193
193
  warnings
194
194
  };
195
195
 
196
- } catch (error) {
197
- logError('[Reconcile] Pipeline error:', error);
198
- return {
199
- ooxml: originalOoxml,
200
- isValid: false,
201
- warnings: [`Pipeline error: ${error.message}`],
202
- error: error?.code ? { code: error.code, message: error.message } : undefined
203
- };
204
- }
196
+ } catch (error) {
197
+ logError('[Reconcile] Pipeline error:', error);
198
+ return {
199
+ ooxml: originalOoxml,
200
+ isValid: false,
201
+ warnings: [`Pipeline error: ${error.message}`],
202
+ error: error?.code ? { code: error.code, message: error.message } : undefined
203
+ };
204
+ }
205
205
  }
206
206
 
207
207
  /**
@@ -210,19 +210,19 @@ export class ReconciliationPipeline {
210
210
  * @param {string} ooxml - Generated OOXML
211
211
  * @returns {{ isValid: boolean, errors: string[] }}
212
212
  */
213
- validateBasic(ooxml) {
213
+ validateBasic(ooxml) {
214
214
  const errors = [];
215
215
 
216
216
  try {
217
217
  // Check for well-formed XML by wrapping in namespace container
218
218
  const wrappedXml = `<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${ooxml}</root>`;
219
- const parsed = parseOoxmlSafe(wrappedXml, 'application/xml');
220
- const doc = parsed.doc;
221
-
222
- if (parsed.error || !doc) {
223
- errors.push('Generated OOXML is not well-formed XML: ' + (parsed.error?.message || 'parse error'));
224
- return { isValid: false, errors };
225
- }
219
+ const parsed = parseOoxmlSafe(wrappedXml, 'application/xml');
220
+ const doc = parsed.doc;
221
+
222
+ if (parsed.error || !doc) {
223
+ errors.push('Generated OOXML is not well-formed XML: ' + (parsed.error?.message || 'parse error'));
224
+ return { isValid: false, errors };
225
+ }
226
226
 
227
227
  const parseError = getXmlParseError(doc);
228
228
  if (parseError) {
@@ -242,40 +242,40 @@ export class ReconciliationPipeline {
242
242
  isValid: errors.length === 0,
243
243
  errors
244
244
  };
245
- }
246
-
247
- /**
248
- * Decides if basic output validation should run for this pipeline instance.
249
- *
250
- * Modes:
251
- * - `always`: validate whenever `validateOutput` is true
252
- * - `never`: never validate
253
- * - `auto` (default): skip only in production web runtime
254
- *
255
- * @returns {boolean}
256
- */
257
- shouldRunValidation() {
258
- if (!this.validateOutput) return false;
259
-
260
- if (this.validationMode === 'always') return true;
261
- if (this.validationMode === 'never') return false;
262
-
263
- // Auto mode: avoid extra parse round-trips in Word Online production.
264
- return !(this.isWebPlatform && isProductionBuild());
265
- }
266
-
267
- /**
268
- * Yields to the event loop for large operations to keep web UI responsive.
269
- *
270
- * @param {number} runCount - Run model size
271
- * @param {number} charCount - Text size
272
- * @returns {Promise<void>}
273
- */
274
- async maybeYield(runCount, charCount) {
275
- if (!this.enableEventLoopYielding) return;
276
- if (runCount <= this.yieldRunThreshold && charCount <= this.yieldCharThreshold) return;
277
- await yieldToEventLoop();
278
- }
245
+ }
246
+
247
+ /**
248
+ * Decides if basic output validation should run for this pipeline instance.
249
+ *
250
+ * Modes:
251
+ * - `always`: validate whenever `validateOutput` is true
252
+ * - `never`: never validate
253
+ * - `auto` (default): skip only in production web runtime
254
+ *
255
+ * @returns {boolean}
256
+ */
257
+ shouldRunValidation() {
258
+ if (!this.validateOutput) return false;
259
+
260
+ if (this.validationMode === 'always') return true;
261
+ if (this.validationMode === 'never') return false;
262
+
263
+ // Auto mode: avoid extra parse round-trips in Word Online production.
264
+ return !(this.isWebPlatform && isProductionBuild());
265
+ }
266
+
267
+ /**
268
+ * Yields to the event loop for large operations to keep web UI responsive.
269
+ *
270
+ * @param {number} runCount - Run model size
271
+ * @param {number} charCount - Text size
272
+ * @returns {Promise<void>}
273
+ */
274
+ async maybeYield(runCount, charCount) {
275
+ if (!this.enableEventLoopYielding) return;
276
+ if (runCount <= this.yieldRunThreshold && charCount <= this.yieldCharThreshold) return;
277
+ await yieldToEventLoop();
278
+ }
279
279
 
280
280
  /**
281
281
  * Wraps the reconciled content for document insertion.
@@ -303,10 +303,10 @@ export class ReconciliationPipeline {
303
303
  originalRunModel,
304
304
  originalText,
305
305
  generateRedlines: this.generateRedlines,
306
- author: this.author,
307
- font: this.font,
308
- revisionIdAllocator: this.revisionIdAllocator,
309
- numberingService: this.numberingService
306
+ author: this.author,
307
+ font: this.font,
308
+ revisionIdAllocator: this.revisionIdAllocator,
309
+ numberingService: this.numberingService
310
310
  });
311
311
  }
312
312
 
@@ -337,10 +337,12 @@ export class ReconciliationPipeline {
337
337
  };
338
338
  }
339
339
 
340
- const tableOoxml = generateTableOoxml(tableData, {
341
- generateRedlines: this.generateRedlines,
342
- author: this.author,
343
- revisionIdAllocator: this.revisionIdAllocator
340
+ const tableOoxml = generateTableOoxml(tableData, {
341
+ generateRedlines: this.generateRedlines,
342
+ author: this.author,
343
+ revisionIdAllocator: this.revisionIdAllocator,
344
+ // Word-native inserted table: tracked rows so Reject All removes the whole table.
345
+ trackAsBlock: true
344
346
  });
345
347
 
346
348
  return {
@@ -16,6 +16,7 @@ import {
16
16
  rejectTrackedChangesInOoxml
17
17
  } from '../services/revision-comment-management.js';
18
18
  import { WORD_TASK_CASES } from '../tests/fixtures/word-task-cases.mjs';
19
+ import { mergeNumberingParts } from '../services/numbering-allocation.js';
19
20
 
20
21
  const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
21
22
  configureXmlProvider({ DOMParser, XMLSerializer });
@@ -56,7 +57,8 @@ for (const testCase of cases) {
56
57
  sourceDocumentXml,
57
58
  testCase.batchOperations,
58
59
  'Validation',
59
- null,
60
+ // A numbering binding resolves against the source numbering part, as it does in a real package.
61
+ testCase.sourceNumbering ? { numberingXml: testCase.packageParts?.numberingXml } : null,
60
62
  operationOptions
61
63
  )
62
64
  : await applyOperationToDocumentXml(
@@ -118,7 +120,7 @@ for (const testCase of cases) {
118
120
  }
119
121
 
120
122
  if (testCase.requiredNumberingFormats) {
121
- const numberingXml = testCase.packageParts?.numberingXml || result.numberingXml || '';
123
+ const numberingXml = [testCase.packageParts?.numberingXml, result.numberingXml, ...(result.numberingXmlParts || [])].filter(Boolean).join('');
122
124
  for (const format of testCase.requiredNumberingFormats) {
123
125
  if (!numberingXml.includes(`<w:numFmt w:val="${format}"`)) {
124
126
  console.error(`FAIL ${testCase.name}: required numbering format ${format} is missing`);
@@ -162,14 +164,20 @@ for (const testCase of cases) {
162
164
  continue;
163
165
  }
164
166
 
167
+ // A batch returns numbering payloads as parts; merge them into the source part through the one allocator merge.
168
+ const batchNumberingXml = Array.isArray(result.numberingXmlParts) && result.numberingXmlParts.length > 0
169
+ ? mergeNumberingParts(testCase.packageParts?.numberingXml || '', result.numberingXmlParts)
170
+ : null;
171
+ const resultNumberingXml = result.numberingXml || batchNumberingXml || null;
165
172
  writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
166
- if (result.numberingXml) {
167
- writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
173
+ if (resultNumberingXml) {
174
+ writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), resultNumberingXml, 'utf8');
168
175
  }
169
176
 
170
177
  const packageParts = {
171
- numberingXml: result.numberingXml || null,
172
- ...(testCase.packageParts || {})
178
+ numberingXml: resultNumberingXml,
179
+ ...(testCase.packageParts || {}),
180
+ ...(batchNumberingXml ? { numberingXml: batchNumberingXml } : {})
173
181
  };
174
182
  let packageEntries;
175
183
  let docx;
@@ -228,6 +236,11 @@ for (const testCase of cases) {
228
236
  sourceText: testCase.sourceText || testCase.original,
229
237
  modifiedText: preprocessMarkdown(testCase.modified).cleanText,
230
238
  requiredNumberingFormats: testCase.requiredNumberingFormats || [],
239
+ ...(testCase.expectedAcceptedListStrings ? {
240
+ expectedAcceptedListStrings: testCase.expectedAcceptedListStrings,
241
+ expectedRejectedListStrings: testCase.expectedRejectedListStrings
242
+ } : {}),
243
+ ...(testCase.expectedAcceptedListGroupSizes ? { expectedAcceptedListGroupSizes: testCase.expectedAcceptedListGroupSizes } : {}),
231
244
  untouchedPartSha256
232
245
  };
233
246
  writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');