@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
@@ -4,14 +4,14 @@
4
4
  * Converts patched run model back to OOXML with track changes.
5
5
  */
6
6
 
7
- import { RunKind, escapeXml, createRevisionMetadata } from '../core/types.js';
8
- import { getApplicableFormatHints } from './markdown-processor.js';
9
- import { serializeXml } from '../adapters/xml-adapter.js';
10
- import { warn } from '../adapters/logger.js';
11
- import { buildDocumentFragmentPackage } from '../services/package-builder.js';
12
- import { getDefaultAuthor } from '../adapters/config.js';
13
-
14
- const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
7
+ import { RunKind, escapeXml, createRevisionMetadata } from '../core/types.js';
8
+ import { getApplicableFormatHints } from './markdown-processor.js';
9
+ import { serializeXml } from '../adapters/xml-adapter.js';
10
+ import { warn } from '../adapters/logger.js';
11
+ import { buildDocumentFragmentPackage } from '../services/package-builder.js';
12
+ import { getDefaultAuthor } from '../adapters/config.js';
13
+
14
+ const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
15
15
 
16
16
  /**
17
17
  * Serializes a patched run model to OOXML.
@@ -22,48 +22,48 @@ const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
22
22
  * @param {import('../core/types.js').SerializationOptions} [options={}] - Serialization options
23
23
  * @returns {string} OOXML paragraph string (WITHOUT namespace - added by wrapper)
24
24
  */
25
- export function serializeToOoxml(patchedModel, pPr, formatHints = [], options = {}) {
26
- const serializationOptions = normalizeSerializationOptions(options);
27
- const { generateRedlines } = serializationOptions;
28
- const paragraphs = [];
29
- let currentPPrXml = '';
30
- let currentPPrElement = null;
31
- let currentRuns = [];
25
+ export function serializeToOoxml(patchedModel, pPr, formatHints = [], options = {}) {
26
+ const serializationOptions = normalizeSerializationOptions(options);
27
+ const { generateRedlines } = serializationOptions;
28
+ const paragraphs = [];
29
+ let currentPPrXml = '';
30
+ let currentPPrElement = null;
31
+ let currentRuns = [];
32
32
 
33
33
  // Helper to flush accumulated runs into a paragraph
34
34
  function flushParagraph() {
35
35
  if (currentRuns.length > 0 || paragraphs.length === 0) {
36
36
  // Build paragraph properties - handle both string and DOM element
37
- let pPrContent = '';
38
- if (currentPPrXml) {
39
- pPrContent = stripNamespaceDeclarations(currentPPrXml);
40
- } else if (currentPPrElement) {
41
- currentPPrXml = stripNamespaceDeclarations(serializeXml(currentPPrElement));
42
- pPrContent = currentPPrXml;
43
- } else if (pPr) {
44
- // Fallback to legacy pPr if no PARAGRAPH_START was seen
45
- if (typeof pPr === 'string') {
46
- pPrContent = pPr;
47
- } else {
48
- pPrContent = serializeXml(pPr);
49
- }
50
- pPrContent = stripNamespaceDeclarations(pPrContent);
51
- }
52
- paragraphs.push(`<w:p>${pPrContent}${currentRuns.join('')}</w:p>`);
53
- currentRuns = [];
54
- }
37
+ let pPrContent = '';
38
+ if (currentPPrXml) {
39
+ pPrContent = stripNamespaceDeclarations(currentPPrXml);
40
+ } else if (currentPPrElement) {
41
+ currentPPrXml = stripNamespaceDeclarations(serializeXml(currentPPrElement));
42
+ pPrContent = currentPPrXml;
43
+ } else if (pPr) {
44
+ // Fallback to legacy pPr if no PARAGRAPH_START was seen
45
+ if (typeof pPr === 'string') {
46
+ pPrContent = pPr;
47
+ } else {
48
+ pPrContent = serializeXml(pPr);
49
+ }
50
+ pPrContent = stripNamespaceDeclarations(pPrContent);
51
+ }
52
+ paragraphs.push(`<w:p>${pPrContent}${currentRuns.join('')}</w:p>`);
53
+ currentRuns = [];
54
+ }
55
55
  }
56
56
 
57
57
  for (const item of patchedModel) {
58
58
  switch (item.kind) {
59
59
  case RunKind.PARAGRAPH_START:
60
60
  // Flush previous paragraph before starting a new one
61
- if (currentRuns.length > 0 || paragraphs.length > 0) {
62
- flushParagraph();
63
- }
64
- currentPPrXml = item.pPrXml || '';
65
- currentPPrElement = item.pPrElement || null;
66
- break;
61
+ if (currentRuns.length > 0 || paragraphs.length > 0) {
62
+ flushParagraph();
63
+ }
64
+ currentPPrXml = item.pPrXml || '';
65
+ currentPPrElement = item.pPrElement || null;
66
+ break;
67
67
 
68
68
  case RunKind.TEXT:
69
69
  currentRuns.push(buildRunXmlWithHints(item, formatHints, serializationOptions));
@@ -87,11 +87,11 @@ export function serializeToOoxml(patchedModel, pPr, formatHints = [], options =
87
87
 
88
88
  case RunKind.BOOKMARK:
89
89
  case RunKind.HYPERLINK:
90
- // Pass through original XML - but strip any namespace declarations
91
- if (item.nodeXml) {
92
- currentRuns.push(stripNamespaceDeclarations(item.nodeXml));
93
- }
94
- break;
90
+ // Pass through original XML - but strip any namespace declarations
91
+ if (item.nodeXml) {
92
+ currentRuns.push(stripNamespaceDeclarations(item.nodeXml));
93
+ }
94
+ break;
95
95
 
96
96
  case RunKind.CONTAINER_START:
97
97
  if (item.containerKind === 'sdt') {
@@ -134,29 +134,29 @@ export function serializeToOoxml(patchedModel, pPr, formatHints = [], options =
134
134
  * @param {import('../core/types.js').SerializationOptions|string|undefined|null} options - Raw options
135
135
  * @returns {import('../core/types.js').SerializationOptions}
136
136
  */
137
- function normalizeSerializationOptions(options) {
138
- // Backward compatibility: accept legacy `font` string signature.
139
- if (typeof options === 'string') {
140
- return {
141
- author: getDefaultAuthor(),
142
- generateRedlines: true,
143
- font: options,
144
- revisionIdAllocator: null
145
- };
146
- }
147
-
148
- const normalized = options && typeof options === 'object' ? options : {};
149
- const resolvedAuthor = typeof normalized.author === 'string' && normalized.author.trim()
150
- ? normalized.author.trim()
151
- : getDefaultAuthor();
152
-
153
- return {
154
- author: resolvedAuthor,
155
- generateRedlines: normalized.generateRedlines ?? true,
156
- font: normalized.font ?? null,
157
- revisionIdAllocator: normalized.revisionIdAllocator ?? null
158
- };
159
- }
137
+ function normalizeSerializationOptions(options) {
138
+ // Backward compatibility: accept legacy `font` string signature.
139
+ if (typeof options === 'string') {
140
+ return {
141
+ author: getDefaultAuthor(),
142
+ generateRedlines: true,
143
+ font: options,
144
+ revisionIdAllocator: null
145
+ };
146
+ }
147
+
148
+ const normalized = options && typeof options === 'object' ? options : {};
149
+ const resolvedAuthor = typeof normalized.author === 'string' && normalized.author.trim()
150
+ ? normalized.author.trim()
151
+ : getDefaultAuthor();
152
+
153
+ return {
154
+ author: resolvedAuthor,
155
+ generateRedlines: normalized.generateRedlines ?? true,
156
+ font: normalized.font ?? null,
157
+ revisionIdAllocator: normalized.revisionIdAllocator ?? null
158
+ };
159
+ }
160
160
 
161
161
  /**
162
162
  * Builds a run XML element, applying format hints if applicable.
@@ -166,10 +166,10 @@ function normalizeSerializationOptions(options) {
166
166
  * @param {import('../core/types.js').SerializationOptions} options - Serialization options
167
167
  * @returns {string}
168
168
  */
169
- function buildRunXmlWithHints(item, formatHints, options = {}) {
170
- const applicableHints = getApplicableFormatHints(formatHints, item.startOffset, item.endOffset);
171
- const font = options?.font ?? null;
172
- let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
169
+ function buildRunXmlWithHints(item, formatHints, options = {}) {
170
+ const applicableHints = getApplicableFormatHints(formatHints, item.startOffset, item.endOffset);
171
+ const font = options?.font ?? null;
172
+ let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
173
173
 
174
174
  if (font) {
175
175
  cleanRPr = applyFont(cleanRPr, font);
@@ -219,7 +219,24 @@ function buildRunXmlWithHints(item, formatHints, options = {}) {
219
219
  function buildSimpleRun(text, rPrXml) {
220
220
  if (!text) return '';
221
221
  const rPr = rPrXml || '';
222
- return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
222
+ return `<w:r>${rPr}${buildRunContent(text, 'w:t')}</w:r>`;
223
+ }
224
+
225
+ /**
226
+ * Builds run content, restoring in-run line breaks and tabs (ingested from
227
+ * w:br/w:cr/w:tab as "\n"/"\t") as elements; raw control characters inside
228
+ * w:t or w:delText render as spaces in Word.
229
+ *
230
+ * @param {string} text - Run text
231
+ * @param {'w:t'|'w:delText'} textTag - Text element name
232
+ * @returns {string}
233
+ */
234
+ function buildRunContent(text, textTag) {
235
+ return String(text).split(/(\r\n|[\n\r\v\t])/).map(part => {
236
+ if (part === '\t') return '<w:tab/>';
237
+ if (part === '\n' || part === '\r' || part === '\r\n' || part === '\v') return '<w:br/>';
238
+ return part ? `<${textTag} xml:space="preserve">${escapeXml(part)}</${textTag}>` : '';
239
+ }).join('');
223
240
  }
224
241
 
225
242
  /**
@@ -229,21 +246,21 @@ function buildSimpleRun(text, rPrXml) {
229
246
  * @param {import('../core/types.js').SerializationOptions} options - Serialization options
230
247
  * @returns {string}
231
248
  */
232
- function buildDeletionXml(item, options = {}) {
233
- const metadata = createRevisionMetadata(
234
- options.author ?? getDefaultAuthor(),
235
- options.revisionIdAllocator,
236
- 'del'
237
- );
238
- const font = options.font ?? null;
239
- let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
249
+ function buildDeletionXml(item, options = {}) {
250
+ const metadata = createRevisionMetadata(
251
+ options.author ?? getDefaultAuthor(),
252
+ options.revisionIdAllocator,
253
+ 'del'
254
+ );
255
+ const font = options.font ?? null;
256
+ let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
240
257
 
241
258
  if (font) {
242
259
  rPr = applyFont(rPr, font);
243
260
  }
244
261
 
245
262
  return `<w:del w:id="${metadata.id}" w:author="${escapeXml(metadata.author)}" w:date="${metadata.date}">` +
246
- `<w:r>${rPr}<w:delText xml:space="preserve">${escapeXml(item.text)}</w:delText></w:r>` +
263
+ `<w:r>${rPr}${buildRunContent(item.text, 'w:delText')}</w:r>` +
247
264
  `</w:del>`;
248
265
  }
249
266
 
@@ -255,18 +272,18 @@ function buildDeletionXml(item, options = {}) {
255
272
  * @param {import('../core/types.js').SerializationOptions} options - Serialization options
256
273
  * @returns {string}
257
274
  */
258
- function buildInsertionXml(item, formatHints, options = {}) {
259
- const metadata = createRevisionMetadata(
260
- options.author ?? getDefaultAuthor(),
261
- options.revisionIdAllocator,
262
- 'ins'
263
- );
275
+ function buildInsertionXml(item, formatHints, options = {}) {
276
+ const metadata = createRevisionMetadata(
277
+ options.author ?? getDefaultAuthor(),
278
+ options.revisionIdAllocator,
279
+ 'ins'
280
+ );
264
281
  const font = options.font ?? null;
265
282
 
266
283
  // Build the inner run content with format hints
267
284
  const applicableHints = getApplicableFormatHints(formatHints, item.startOffset, item.endOffset);
268
285
  let innerContent = '';
269
- let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
286
+ let cleanRPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
270
287
 
271
288
  if (font) {
272
289
  cleanRPr = applyFont(cleanRPr, font);
@@ -383,7 +400,7 @@ export function wrapInDocumentFragment(paragraphXml, options = {}) {
383
400
  * @param {import('../core/types.js').DocumentFragmentOptions|boolean|undefined|null} options - Raw options
384
401
  * @returns {import('../core/types.js').DocumentFragmentOptions}
385
402
  */
386
- function normalizeFragmentOptions(options) {
403
+ function normalizeFragmentOptions(options) {
387
404
  if (typeof options === 'boolean') {
388
405
  return { includeNumbering: options };
389
406
  }
@@ -397,9 +414,9 @@ function normalizeFragmentOptions(options) {
397
414
  numberingXml: options.numberingXml ?? null,
398
415
  appendTrailingParagraph: options.appendTrailingParagraph ?? true
399
416
  };
400
- }
401
-
402
- function stripNamespaceDeclarations(xml) {
403
- return xml ? xml.replace(XMLNS_ATTR_REGEX, '') : '';
404
- }
417
+ }
418
+
419
+ function stripNamespaceDeclarations(xml) {
420
+ return xml ? xml.replace(XMLNS_ATTR_REGEX, '') : '';
421
+ }
405
422
 
@@ -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');
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Builds the Word revision-semantics fixture matrix (WP-2 COM backstop).
3
+ *
4
+ * node scripts/generate-word-revision-semantics-matrix.mjs
5
+ * powershell -File scripts/word-revision-semantics-matrix.ps1
6
+ *
7
+ * Writes tests/fixtures/word-revision-semantics/cases.json and one minimal .docx
8
+ * per case; the PowerShell script drives Word and writes expectations.json.
9
+ */
10
+ import { mkdirSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { buildZip } from './lib/minimal-zip.mjs';
14
+
15
+ const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'tests', 'fixtures', 'word-revision-semantics');
16
+ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
17
+ const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
18
+ const PKG = 'http://schemas.openxmlformats.org/package/2006/relationships';
19
+ const DATE = '2026-01-01T00:00:00Z';
20
+
21
+ let id = 100;
22
+ const rev = (tag, author = 'A') => `<w:${tag} w:id="${id++}" w:author="${author}" w:date="${DATE}"/>`;
23
+ const run = t => `<w:r><w:t xml:space="preserve">${t}</w:t></w:r>`;
24
+ const insRun = (t, a = 'A') => `<w:ins w:id="${id++}" w:author="${a}" w:date="${DATE}"><w:r><w:t xml:space="preserve">${t}</w:t></w:r></w:ins>`;
25
+ const delRun = (t, a = 'A') => `<w:del w:id="${id++}" w:author="${a}" w:date="${DATE}"><w:r><w:delText xml:space="preserve">${t}</w:delText></w:r></w:del>`;
26
+ const NUM = '<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>';
27
+ const LIST = '<w:pStyle w:val="ListParagraph"/>' + NUM;
28
+ const mark = (kind, a = 'A') => `<w:rPr>${rev(kind, a)}</w:rPr>`;
29
+ // pPr child order: style/numPr..., rPr (mark), sectPr, pPrChange
30
+ const para = (content, { ppr = '', mark: m = '', extra = '' } = {}) =>
31
+ `<w:p>${ppr || m || extra ? `<w:pPr>${ppr}${m}${extra}</w:pPr>` : ''}${content}</w:p>`;
32
+ const cell = inner => `<w:tc><w:tcPr><w:tcW w:w="4000" w:type="dxa"/></w:tcPr>${inner}</w:tc>`;
33
+ const table = rows => `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>${rows}</w:tbl>`;
34
+ const row = (inner, trPr = '') => `<w:tr>${trPr ? `<w:trPr>${trPr}</w:trPr>` : ''}${inner}</w:tr>`;
35
+ const SECT = '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>';
36
+
37
+ const defs = [];
38
+ function add(name, description, build) { id = 100; defs.push({ name, description, bodyXml: build() + SECT }); }
39
+ const P = t => para(run(t));
40
+ const pprChange = old => `<w:pPrChange w:id="${id++}" w:author="A" w:date="${DATE}"><w:pPr>${old}</w:pPr></w:pPrChange>`;
41
+ const BOLD_MARK = '<w:rPr><w:b/></w:rPr>';
42
+
43
+ add('ins-mark-mid-plain', 'Mid-body paragraph with inserted content and inserted mark',
44
+ () => P('Alpha.') + para(insRun('Inserted.'), { mark: mark('ins') }) + P('Omega.'));
45
+ add('ins-mark-mid-untracked-content', 'Mid-body paragraph, inserted mark, untracked content',
46
+ () => P('Alpha.') + para(run('Untracked.'), { mark: mark('ins') }) + P('Omega.'));
47
+ add('ins-mark-list-then-plain', 'List paragraph with inserted mark followed by plain paragraph',
48
+ () => P('Alpha.') + para(insRun('Item.'), { ppr: LIST, mark: mark('ins') }) + P('Plain after.'));
49
+ add('ins-mark-last-prev-deleted', 'Last body paragraph inserted mark; preceding paragraph mark deleted (replacement shape)',
50
+ () => P('First.') + para(delRun('Second paragraph.'), { mark: mark('del') })
51
+ + para(insRun('Item one.'), { ppr: LIST, mark: mark('ins') })
52
+ + para(insRun('Item two.'), { ppr: LIST, mark: mark('ins') }));
53
+ add('ins-mark-last-prev-plain', 'Last body paragraph inserted mark; preceding paragraph plain',
54
+ () => P('First.') + P('Second paragraph.') + para(insRun('Added last.'), { ppr: LIST, mark: mark('ins') }));
55
+ add('ins-mark-last-plain-style', 'Last body paragraph inserted mark, no list props',
56
+ () => P('First.') + para(insRun('Added last.'), { mark: mark('ins') }));
57
+ add('ins-mark-cell-sole', 'Sole paragraph in a table cell has inserted mark',
58
+ () => P('Intro.') + table(row(cell(para(insRun('Cell ins.'), { mark: mark('ins') })))) + P('After table.'));
59
+ add('ins-mark-cell-last-of-two', 'Last of two paragraphs in a cell has inserted mark',
60
+ () => P('Intro.') + table(row(cell(P('Cell one.') + para(insRun('Cell two.'), { mark: mark('ins') })))) + P('After table.'));
61
+ add('ins-mark-before-table', 'Paragraph with inserted mark directly before a table',
62
+ () => P('Intro.') + para(insRun('Inserted.'), { mark: mark('ins') }) + table(row(cell(P('Cell.')))) + P('After table.'));
63
+ add('ins-mark-bookmark-between', 'Inserted mark paragraph with bookmark between paragraphs',
64
+ () => P('Alpha.') + para('<w:bookmarkStart w:id="900" w:name="bm1"/>' + insRun('Inserted.') + '<w:bookmarkEnd w:id="900"/>', { mark: mark('ins') }) + P('Omega.'));
65
+
66
+ add('del-mark-mid-plain', 'Mid-body paragraph with deleted content and deleted mark',
67
+ () => P('Alpha.') + para(delRun('Deleted.'), { mark: mark('del') }) + P('Omega.'));
68
+ add('del-mark-mid-untracked-content', 'Mid-body paragraph, deleted mark, untracked content',
69
+ () => P('Alpha.') + para(run('Untracked.'), { mark: mark('del') }) + P('Omega.'));
70
+ add('del-mark-list-then-plain', 'List paragraph with deleted mark followed by plain paragraph',
71
+ () => P('Alpha.') + para(delRun('Item.'), { ppr: LIST, mark: mark('del') }) + P('Plain after.'));
72
+ add('del-mark-last', 'Last body paragraph with deleted mark',
73
+ () => P('First.') + para(delRun('Gone last.'), { mark: mark('del') }));
74
+ add('del-mark-last-list-prev-plain', 'Last body list paragraph with deleted mark; preceding plain',
75
+ () => P('First.') + P('Second.') + para(delRun('Gone last.'), { ppr: LIST, mark: mark('del') }));
76
+ add('del-mark-cell-sole', 'Sole cell paragraph with deleted mark',
77
+ () => P('Intro.') + table(row(cell(para(delRun('Cell del.'), { mark: mark('del') })))) + P('After table.'));
78
+ add('del-mark-cell-last-of-two', 'Last of two cell paragraphs with deleted mark',
79
+ () => P('Intro.') + table(row(cell(P('Cell one.') + para(delRun('Cell two.'), { mark: mark('del') })))) + P('After table.'));
80
+ add('del-mark-before-table', 'Paragraph with deleted mark directly before a table',
81
+ () => P('Intro.') + para(delRun('Deleted.'), { mark: mark('del') }) + table(row(cell(P('Cell.')))) + P('After table.'));
82
+ add('del-mark-bookmark-between', 'Deleted mark paragraph with bookmark between paragraphs',
83
+ () => P('Alpha.') + para('<w:bookmarkStart w:id="900" w:name="bm1"/>' + delRun('Deleted.') + '<w:bookmarkEnd w:id="900"/>', { mark: mark('del') }) + P('Omega.'));
84
+
85
+ add('pprchange-restores-list', 'pPrChange: current Heading1, previous list item; mark rPr has bold',
86
+ () => P('Alpha.') + para(run('Was a list item.'), { ppr: '<w:pStyle w:val="Heading1"/>', mark: BOLD_MARK, extra: pprChange(LIST) }) + P('Omega.'));
87
+ add('pprchange-removes-list', 'pPrChange: current list item, previous plain; mark rPr has bold',
88
+ () => P('Alpha.') + para(run('Became a list item.'), { ppr: LIST, mark: BOLD_MARK, extra: pprChange('') }) + P('Omega.'));
89
+ add('del-mark-with-sectpr', 'Deleted-mark paragraph carrying a section break',
90
+ () => para(delRun('Section one end.'), { mark: mark('del'), extra: '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>' }) + P('Section two.'));
91
+ add('adjacent-ins-then-del-marks', 'Inserted-mark paragraph followed by deleted-mark paragraph',
92
+ () => P('Alpha.') + para(insRun('New.'), { mark: mark('ins') }) + para(delRun('Old.'), { mark: mark('del') }) + P('Keep.'));
93
+ add('adjacent-del-then-ins-marks', 'Deleted-mark paragraph followed by inserted-mark paragraph',
94
+ () => P('Alpha.') + para(delRun('Old.'), { mark: mark('del') }) + para(insRun('New.'), { mark: mark('ins') }) + P('Keep.'));
95
+ add('mixed-authors-marks', 'Inserted marks by two authors plus a deleted mark by a third',
96
+ () => P('Alpha.') + para(insRun('By A.', 'A'), { mark: mark('ins', 'A') })
97
+ + para(insRun('By B.', 'B'), { mark: mark('ins', 'B') })
98
+ + para(delRun('By C.', 'C'), { mark: mark('del', 'C') }) + P('Omega.'));
99
+ add('ins-row', 'Table with an inserted row',
100
+ () => P('Intro.') + table(
101
+ row(cell(P('Existing row.')))
102
+ + row(cell(para(insRun('Inserted row.'), { mark: mark('ins') })), rev('ins'))) + P('After table.'));
103
+ add('del-row', 'Table with a deleted row',
104
+ () => P('Intro.') + table(
105
+ row(cell(P('Existing row.')))
106
+ + row(cell(para(delRun('Deleted row.'), { mark: mark('del') })), rev('del'))) + P('After table.'));
107
+
108
+ // R5: end-of-document table append. insRow = row-level ins with inserted cell marks (library shape).
109
+ const insRow = (text, cols = 2) => row(Array.from({ length: cols }, (_, i) => cell(para(insRun(`${text}${i + 1}`), { mark: mark('ins') }))).join(''), rev('ins'));
110
+ add('table-append-end-trailing-ins-mark', 'Table appended after the final paragraph: inserted rows, then an empty trailing paragraph with an inserted mark (library shape)',
111
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')) + para('', { mark: mark('ins') }));
112
+ add('table-append-end-word-native', 'Table appended after the final paragraph as Word writes it: final paragraph mark inserted, inserted rows, untracked empty trailing paragraph',
113
+ () => P('Alpha.') + para(run('Final paragraph.'), { mark: mark('ins') }) + table(insRow('R1C') + insRow('R2C')) + P(''));
114
+ add('table-append-end-untracked-trailing', 'Table appended after the final paragraph: inserted rows followed by an untracked empty trailing paragraph',
115
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')) + P(''));
116
+ add('table-append-end-no-trailing-paragraph', 'Table appended at the very end with no paragraph after it: Word supplies an untracked empty paragraph (the pretty-printed-body defect shape)',
117
+ () => P('Alpha.') + P('Final paragraph.') + table(insRow('R1C') + insRow('R2C')));
118
+
119
+ const wrap = body => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${W}" xmlns:r="${R}"><w:body>${body}</w:body></w:document>`;
120
+ const styles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${W}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/></w:pPr></w:style></w:styles>`;
121
+ const numbering = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering xmlns:w="${W}"><w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="hybridMultilevel"/><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="-"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>`;
122
+ const entries = body => [
123
+ { name: '[Content_Types].xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>` },
124
+ { name: '_rels/.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${PKG}"><Relationship Id="rId1" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>` },
125
+ { name: 'word/document.xml', data: wrap(body) },
126
+ { name: 'word/_rels/document.xml.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${PKG}"><Relationship Id="rId1" Type="${R}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="${R}/numbering" Target="numbering.xml"/></Relationships>` },
127
+ { name: 'word/styles.xml', data: styles },
128
+ { name: 'word/numbering.xml', data: numbering }
129
+ ];
130
+
131
+ mkdirSync(OUT, { recursive: true });
132
+ for (const def of defs) writeFileSync(join(OUT, `${def.name}.docx`), buildZip(entries(def.bodyXml)));
133
+ writeFileSync(join(OUT, 'cases.json'), JSON.stringify(defs, null, 2) + '\n');
134
+ console.log(`Wrote ${defs.length} cases to ${OUT}`);
@@ -50,6 +50,7 @@ function assertSyntheticClaim(testCase, structure) {
50
50
  || testCase.original.includes('\n') || testCase.modified.includes('\n'),
51
51
  'formatted-runs': () => /<(?:w:)?(?:b|i|u)(?:\s|\/|>)/.test(xml) || /(?:\*\*|\*|\+\+)/.test(testCase.modified),
52
52
  list: () => elementCount(xml, 'numPr') > 0
53
+ || (testCase.batchOperations || []).some(operation => operation?.type === 'list-format')
53
54
  || (testCase.operationOptions?.structuredContent === true
54
55
  && /^\s*(?:[-+*]|\d+[.)])\s+/m.test(testCase.modified)),
55
56
  table: () => elementCount(xml, 'tbl') > 0
@@ -74,8 +75,12 @@ function assertSyntheticClaim(testCase, structure) {
74
75
  }
75
76
 
76
77
  function syntheticTask(testCase) {
77
- if (testCase.operation?.type === 'list-change') return 'list-change';
78
- if (testCase.operation?.type === 'table-reconciliation') return 'table-reconciliation';
78
+ const operationTypes = [testCase.operation, ...(testCase.batchOperations || [])]
79
+ .map(operation => operation?.type)
80
+ .filter(Boolean);
81
+ if (operationTypes.includes('list-change') || operationTypes.includes('list-format')) return 'list-change';
82
+ if (operationTypes.includes('table-reconciliation')) return 'table-reconciliation';
83
+ if (operationTypes.includes('paragraph-format') || operationTypes.includes('character-format')) return 'format';
79
84
  if (testCase.expectAtomicRollback) return 'mixed-batch';
80
85
  if (testCase.expectNoOp) return 'accept-reject';
81
86
  if (testCase.task.startsWith('apply-')) return 'format';
@@ -16,8 +16,8 @@ try {
16
16
  $pdfPath = Join-Path $resolvedOut "multilevel-bullet--$view.pdf"
17
17
  $doc = $word.Documents.OpenNoRepairDialog($resolvedDocx)
18
18
  try {
19
- if ($view -eq 'acceptAll') { $doc.AcceptAllRevisions(); $exportItem = 0 }
20
- elseif ($view -eq 'rejectAll') { $doc.RejectAllRevisions(); $exportItem = 0 }
19
+ if ($view -eq 'acceptAll') { $doc.Revisions.AcceptAll(); $exportItem = 0 }
20
+ elseif ($view -eq 'rejectAll') { $doc.Revisions.RejectAll(); $exportItem = 0 }
21
21
  else { $doc.ShowRevisions = $true; $doc.PrintRevisions = $true; $exportItem = 7 }
22
22
  $pages = $doc.ComputeStatistics(2)
23
23
  $doc.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)
@@ -60,8 +60,8 @@ function Export-View($word, [string]$docxPath, [string]$pdfPath, [string]$viewNa
60
60
  $document = $null
61
61
  try {
62
62
  $document = $word.Documents.OpenNoRepairDialog($docxPath)
63
- if ($viewName -eq 'acceptAll') { $document.AcceptAllRevisions(); $exportItem = 0 }
64
- elseif ($viewName -eq 'rejectAll') { $document.RejectAllRevisions(); $exportItem = 0 }
63
+ if ($viewName -eq 'acceptAll') { $document.Revisions.AcceptAll(); $exportItem = 0 }
64
+ elseif ($viewName -eq 'rejectAll') { $document.Revisions.RejectAll(); $exportItem = 0 }
65
65
  else { $document.ShowRevisions = $true; $document.PrintRevisions = $true; $exportItem = 7 }
66
66
  $pages = $document.ComputeStatistics(2)
67
67
  $document.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)