@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.
- package/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +803 -586
- package/README.md +1018 -901
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4551 -967
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +112 -106
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4690 -1106
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4690 -1106
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +221 -168
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +567 -560
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-mode.js +183 -183
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +51 -1
- package/index.js +47 -3
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -974
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/pipeline/serialization.js +111 -94
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +26 -0
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +215 -0
- package/services/document-operation-contract.js +628 -537
- package/services/document-operation-mutations.js +789 -46
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +612 -436
- package/services/operation-heuristics.js +197 -173
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +634 -594
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +606 -427
- 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}
|
|
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}
|
|
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
|
-
|
|
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
|
|
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 (
|
|
167
|
-
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`),
|
|
173
|
+
if (resultNumberingXml) {
|
|
174
|
+
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), resultNumberingXml, 'utf8');
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
const packageParts = {
|
|
171
|
-
numberingXml:
|
|
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
|
-
|
|
78
|
-
|
|
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.
|
|
20
|
-
elseif ($view -eq 'rejectAll') { $doc.
|
|
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.
|
|
64
|
-
elseif ($viewName -eq 'rejectAll') { $document.
|
|
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)
|