@ansonlai/docx-redline-js 0.1.6 → 0.2.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 +19 -3
- package/README.md +15 -0
- package/core/redline-validation.js +156 -0
- package/dist/docx-redline-js.esm.js +125 -1
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +61 -61
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +79 -23
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/index.d.ts +24 -0
- package/index.js +35 -34
- package/package.json +3 -2
- package/scripts/build.mjs +10 -5
- package/scripts/check-types.mjs +2 -1
- package/scripts/export-validation-fixtures.mjs +73 -16
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
package/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Standalone reconciliation entrypoint (no Word JS API dependencies).
|
|
3
|
-
*/
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Standalone reconciliation entrypoint (no Word JS API dependencies).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
5
|
// Adapters
|
|
6
6
|
export { configureXmlProvider } from './adapters/xml-adapter.js';
|
|
7
7
|
export { configureLogger } from './adapters/logger.js';
|
|
8
8
|
export { setDefaultAuthor, getDefaultAuthor, setPlatform, getPlatform } from './adapters/config.js';
|
|
9
|
-
|
|
10
|
-
// Engine
|
|
9
|
+
|
|
10
|
+
// Engine
|
|
11
11
|
import {
|
|
12
12
|
applyRedlineToOxml as applyRedlineToOxmlEngine,
|
|
13
13
|
sanitizeAiResponse,
|
|
@@ -27,14 +27,15 @@ import {
|
|
|
27
27
|
} from './orchestration/list-structural-fallback.js';
|
|
28
28
|
import { withOoxmlSourceType } from './core/word-xml.js';
|
|
29
29
|
export { containsTrackedChanges } from './core/word-xml.js';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
30
|
+
export { validateRedlineOoxml } from './core/redline-validation.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Standalone-safe redline wrapper.
|
|
34
|
+
*
|
|
35
|
+
* In non-Word runtimes, the engine can return `{ useNativeApi: true, hasChanges: true }`
|
|
36
|
+
* without an OOXML payload for some format-only operations. Standalone callers cannot
|
|
37
|
+
* complete that native fallback path, so normalize to a no-op with warnings.
|
|
38
|
+
*/
|
|
38
39
|
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
39
40
|
const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
|
|
40
41
|
if (result?.useNativeApi && typeof result?.oxml !== 'string') {
|
|
@@ -99,9 +100,9 @@ export async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTa
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
103
|
-
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
104
|
-
|
|
103
|
+
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
104
|
+
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
105
|
+
|
|
105
106
|
/**
|
|
106
107
|
* Applies redline reconciliation, then forces single-line structural list
|
|
107
108
|
* conversion when the redline is a no-op on marker-prefixed list text.
|
|
@@ -234,8 +235,8 @@ export { ingestOoxml } from './pipeline/ingestion.js';
|
|
|
234
235
|
export { ingestWordOoxmlToPlainText, ingestWordOoxmlToMarkdown } from './pipeline/ingestion-export.js';
|
|
235
236
|
export { preprocessMarkdown } from './pipeline/markdown-processor.js';
|
|
236
237
|
export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
|
|
237
|
-
|
|
238
|
-
// Comment engine
|
|
238
|
+
|
|
239
|
+
// Comment engine
|
|
239
240
|
export {
|
|
240
241
|
injectCommentsIntoOoxml,
|
|
241
242
|
injectCommentsIntoPackage,
|
|
@@ -247,15 +248,15 @@ export {
|
|
|
247
248
|
rejectTrackedChangesInOoxml,
|
|
248
249
|
deleteCommentsByAuthorInOoxml
|
|
249
250
|
} from './services/revision-comment-management.js';
|
|
250
|
-
|
|
251
|
+
|
|
251
252
|
// Formatting removal utilities
|
|
252
253
|
export {
|
|
253
254
|
removeFormattingFromRPr,
|
|
254
255
|
applyFormattingRemovalToOoxml,
|
|
255
256
|
applyHighlightToOoxml
|
|
256
257
|
} from './engine/formatting-removal.js';
|
|
257
|
-
|
|
258
|
-
// Table/list tools
|
|
258
|
+
|
|
259
|
+
// Table/list tools
|
|
259
260
|
export { generateTableOoxml } from './services/table-reconciliation.js';
|
|
260
261
|
export { NumberingService } from './services/numbering-service.js';
|
|
261
262
|
export {
|
|
@@ -282,20 +283,20 @@ export {
|
|
|
282
283
|
enforceListBindingOnParagraphNodes,
|
|
283
284
|
stripSingleLineListMarkerPrefix
|
|
284
285
|
} from './orchestration/list-structural-fallback.js';
|
|
285
|
-
|
|
286
|
-
// Core types/constants
|
|
287
|
-
export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
|
|
288
|
-
export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
|
|
286
|
+
|
|
287
|
+
// Core types/constants
|
|
288
|
+
export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
|
|
289
|
+
export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
|
|
289
290
|
export {
|
|
290
291
|
WORD_MAIN_NS,
|
|
291
292
|
getParagraphText,
|
|
292
293
|
getDocumentParagraphNodes,
|
|
293
294
|
normalizeWhitespaceForTargeting,
|
|
294
|
-
isMarkdownTableText,
|
|
295
|
-
parseParagraphReference,
|
|
296
|
-
stripLeadingParagraphMarker,
|
|
297
|
-
splitLeadingParagraphMarker,
|
|
298
|
-
findContainingWordElement,
|
|
295
|
+
isMarkdownTableText,
|
|
296
|
+
parseParagraphReference,
|
|
297
|
+
stripLeadingParagraphMarker,
|
|
298
|
+
splitLeadingParagraphMarker,
|
|
299
|
+
findContainingWordElement,
|
|
299
300
|
findParagraphByReference,
|
|
300
301
|
findParagraphByStrictText,
|
|
301
302
|
findParagraphByBestTextMatch,
|
|
@@ -303,7 +304,7 @@ export {
|
|
|
303
304
|
buildTargetReferenceSnapshot,
|
|
304
305
|
resolveTargetParagraphWithSnapshot
|
|
305
306
|
} from './core/paragraph-targeting.js';
|
|
306
|
-
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
307
|
+
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
307
308
|
export {
|
|
308
309
|
getParagraphListInfo,
|
|
309
310
|
collectContiguousListParagraphBlock,
|
|
@@ -311,5 +312,5 @@ export {
|
|
|
311
312
|
planListInsertionOnlyEdit,
|
|
312
313
|
stripRedundantLeadingListMarkers
|
|
313
314
|
} from './core/list-targeting.js';
|
|
314
|
-
|
|
315
|
-
|
|
315
|
+
|
|
316
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"build": "node scripts/build.mjs",
|
|
59
59
|
"check:types": "node scripts/check-types.mjs",
|
|
60
60
|
"smoke:word": "powershell -File scripts/word-com-smoke.ps1",
|
|
61
|
+
"smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
|
|
61
62
|
"test": "node scripts/run-tests.mjs",
|
|
62
63
|
"test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
|
|
63
64
|
"prepublishOnly": "npm run test:isolation && npm run build"
|
|
@@ -78,6 +79,6 @@
|
|
|
78
79
|
"url": "https://github.com/AnsonLai/docx-redline-js.git"
|
|
79
80
|
},
|
|
80
81
|
"engines": {
|
|
81
|
-
"node": ">=
|
|
82
|
+
"node": ">=20.0.0"
|
|
82
83
|
}
|
|
83
84
|
}
|
package/scripts/build.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { build } from 'esbuild';
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
3
5
|
|
|
4
|
-
const
|
|
6
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const entryPoint = resolve(repoRoot, 'index.js');
|
|
8
|
+
const distDir = resolve(repoRoot, 'dist');
|
|
9
|
+
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
|
|
5
10
|
|
|
6
11
|
// ESM bundle with diff-match-patch inlined (for CDN/browser <script type="module">)
|
|
7
12
|
await build({
|
|
8
|
-
entryPoints: [
|
|
13
|
+
entryPoints: [entryPoint],
|
|
9
14
|
bundle: true,
|
|
10
15
|
format: 'esm',
|
|
11
|
-
outfile: '
|
|
16
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.js'),
|
|
12
17
|
platform: 'neutral', // no Node builtins assumed
|
|
13
18
|
target: 'es2020',
|
|
14
19
|
minify: false, // keep readable for debugging
|
|
@@ -21,10 +26,10 @@ await build({
|
|
|
21
26
|
|
|
22
27
|
// Minified version for production CDN use
|
|
23
28
|
await build({
|
|
24
|
-
entryPoints: [
|
|
29
|
+
entryPoints: [entryPoint],
|
|
25
30
|
bundle: true,
|
|
26
31
|
format: 'esm',
|
|
27
|
-
outfile: '
|
|
32
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
|
|
28
33
|
platform: 'neutral',
|
|
29
34
|
target: 'es2020',
|
|
30
35
|
minify: true,
|
package/scripts/check-types.mjs
CHANGED
|
@@ -8,7 +8,8 @@ const requiredSnippets = [
|
|
|
8
8
|
'export function applyRedlineToOxml',
|
|
9
9
|
'export function acceptTrackedChangesInOoxml',
|
|
10
10
|
'export function rejectTrackedChangesInOoxml',
|
|
11
|
-
'export function deleteCommentsByAuthorInOoxml'
|
|
11
|
+
'export function deleteCommentsByAuthorInOoxml',
|
|
12
|
+
'export function validateRedlineOoxml'
|
|
12
13
|
];
|
|
13
14
|
|
|
14
15
|
for (const snippet of requiredSnippets) {
|
|
@@ -2,7 +2,10 @@ import { mkdirSync, writeFileSync } from 'fs';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
5
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
6
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
5
7
|
import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
|
|
8
|
+
import { buildMinimalDocx } from './lib/minimal-zip.mjs';
|
|
6
9
|
|
|
7
10
|
const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
|
|
8
11
|
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
@@ -14,7 +17,7 @@ mkdirSync(outputDir, { recursive: true });
|
|
|
14
17
|
const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
15
18
|
<w:document xmlns:w="${NS_W}">
|
|
16
19
|
<w:body>
|
|
17
|
-
<w:p><w:r><w:t>${text}</w:t></w:r></w:p>
|
|
20
|
+
<w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>
|
|
18
21
|
<w:sectPr/>
|
|
19
22
|
</w:body>
|
|
20
23
|
</w:document>`;
|
|
@@ -22,47 +25,101 @@ const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="y
|
|
|
22
25
|
const cases = [
|
|
23
26
|
{
|
|
24
27
|
name: 'simple-redline',
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
original: 'The old sentence.',
|
|
29
|
+
modified: 'The new sentence.'
|
|
27
30
|
},
|
|
28
31
|
{
|
|
29
32
|
name: 'paragraph-insert',
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
original: 'one',
|
|
34
|
+
modified: 'one\ntwo'
|
|
32
35
|
},
|
|
33
36
|
{
|
|
34
37
|
name: 'format-only',
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
original: 'Make word bold',
|
|
39
|
+
modified: 'Make **word** bold'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'whitespace-heavy',
|
|
43
|
+
original: 'Alpha beta gamma delta.',
|
|
44
|
+
modified: 'Alpha beta REPLACED delta.'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'unicode-replace',
|
|
48
|
+
original: 'Term 条款 applies to café.',
|
|
49
|
+
modified: 'Term 合同 applies to café 🚀.'
|
|
37
50
|
}
|
|
38
51
|
];
|
|
39
52
|
|
|
53
|
+
let failures = 0;
|
|
54
|
+
|
|
40
55
|
for (const testCase of cases) {
|
|
41
56
|
const result = await applyOperationToDocumentXml(
|
|
42
|
-
testCase.
|
|
43
|
-
testCase.
|
|
57
|
+
baseDocument(testCase.original),
|
|
58
|
+
{ type: 'redline', target: testCase.original, modified: testCase.modified },
|
|
44
59
|
'Validation',
|
|
45
60
|
null,
|
|
46
61
|
{ generateRedlines: true }
|
|
47
62
|
);
|
|
63
|
+
|
|
64
|
+
if (!result?.hasChanges || result?.status === 'error') {
|
|
65
|
+
console.error(`FAIL ${testCase.name}: redline did not apply (status=${result?.status}, error=${result?.error?.message})`);
|
|
66
|
+
failures++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const validation = validateRedlineOoxml(result.documentXml);
|
|
71
|
+
const validationErrors = validation.issues.filter(issue => issue.severity === 'error');
|
|
72
|
+
if (validationErrors.length > 0) {
|
|
73
|
+
console.error(`FAIL ${testCase.name}: validateRedlineOoxml reported ${validationErrors.map(issue => issue.code).join(', ')}`);
|
|
74
|
+
failures++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
|
|
49
79
|
if (result.numberingXml) {
|
|
50
80
|
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
|
|
51
81
|
}
|
|
82
|
+
|
|
83
|
+
const docx = buildMinimalDocx(result.documentXml, { numberingXml: result.numberingXml || null });
|
|
84
|
+
writeFileSync(join(outputDir, `${testCase.name}.docx`), docx);
|
|
85
|
+
|
|
86
|
+
// Expected text is derived from edit *intent*, not from this library's
|
|
87
|
+
// accept/reject transforms, so external consumers (Word COM, LibreOffice)
|
|
88
|
+
// act as independent oracles.
|
|
89
|
+
const expected = {
|
|
90
|
+
name: testCase.name,
|
|
91
|
+
expectedAcceptedText: preprocessMarkdown(testCase.modified).cleanText,
|
|
92
|
+
expectedRejectedText: testCase.original
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
|
|
95
|
+
|
|
96
|
+
console.log(`wrote ${testCase.name}: .document.xml, .docx, .expected.json`);
|
|
52
97
|
}
|
|
53
98
|
|
|
54
99
|
writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
|
|
55
100
|
|
|
56
|
-
|
|
101
|
+
Generated by \`node scripts/export-validation-fixtures.mjs\`.
|
|
102
|
+
|
|
103
|
+
Each case produces:
|
|
57
104
|
|
|
58
|
-
|
|
59
|
-
|
|
105
|
+
- \`<name>.document.xml\` — the generated \`word/document.xml\` payload (for
|
|
106
|
+
XSD validation and manual inspection).
|
|
107
|
+
- \`<name>.docx\` — a minimal package assembled by release tooling only (the
|
|
108
|
+
published library still has no zip dependency).
|
|
109
|
+
- \`<name>.expected.json\` — the accept-all / reject-all plain-text outcomes
|
|
110
|
+
derived from edit intent, used by external-consumer differential checks.
|
|
60
111
|
|
|
61
|
-
|
|
112
|
+
Validation entry points:
|
|
62
113
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
114
|
+
- Word (differential accept/reject): \`npm run smoke:word:diff\`
|
|
115
|
+
- LibreOffice parse check: \`soffice --headless --convert-to pdf *.docx\`
|
|
116
|
+
- Schema check: \`xmllint --noout --schema wml.xsd *.document.xml\`
|
|
117
|
+
(transitional schemas from ECMA-376 Part 4; see docs/VALIDATION.md)
|
|
66
118
|
`, 'utf8');
|
|
67
119
|
|
|
120
|
+
if (failures > 0) {
|
|
121
|
+
console.error(`\n${failures} fixture case(s) failed.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
console.log(`Wrote validation fixtures to ${outputDir}`);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal zip writer for assembling validation .docx fixtures.
|
|
3
|
+
*
|
|
4
|
+
* Script-only helper (not part of the published API surface) so the package
|
|
5
|
+
* keeps its no-zip-dependency guarantee while release tooling can still emit
|
|
6
|
+
* real .docx files for Word/LibreOffice validation. Uses deflate via
|
|
7
|
+
* node:zlib and a fixed timestamp for deterministic output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deflateRawSync } from 'zlib';
|
|
11
|
+
|
|
12
|
+
const CRC_TABLE = (() => {
|
|
13
|
+
const table = new Uint32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n++) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) {
|
|
17
|
+
c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
+
}
|
|
19
|
+
table[n] = c >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
function crc32(buffer) {
|
|
25
|
+
let crc = 0xFFFFFFFF;
|
|
26
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
27
|
+
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
|
|
28
|
+
}
|
|
29
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fixed DOS date/time (2026-01-01 00:00:00) keeps fixture bytes deterministic.
|
|
33
|
+
const DOS_TIME = 0;
|
|
34
|
+
const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds a zip archive.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{ name: string, data: Buffer|string }>} entries - Entry names
|
|
40
|
+
* must use forward slashes (OPC requirement for .docx parts).
|
|
41
|
+
* @returns {Buffer}
|
|
42
|
+
*/
|
|
43
|
+
export function buildZip(entries) {
|
|
44
|
+
const localParts = [];
|
|
45
|
+
const centralParts = [];
|
|
46
|
+
let offset = 0;
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const nameBytes = Buffer.from(entry.name, 'utf8');
|
|
50
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
|
|
51
|
+
const crc = crc32(data);
|
|
52
|
+
|
|
53
|
+
const deflated = deflateRawSync(data, { level: 9 });
|
|
54
|
+
const useDeflate = deflated.length < data.length;
|
|
55
|
+
const method = useDeflate ? 8 : 0;
|
|
56
|
+
const payload = useDeflate ? deflated : data;
|
|
57
|
+
|
|
58
|
+
const localHeader = Buffer.alloc(30);
|
|
59
|
+
localHeader.writeUInt32LE(0x04034B50, 0);
|
|
60
|
+
localHeader.writeUInt16LE(20, 4); // version needed
|
|
61
|
+
localHeader.writeUInt16LE(0, 6); // flags
|
|
62
|
+
localHeader.writeUInt16LE(method, 8);
|
|
63
|
+
localHeader.writeUInt16LE(DOS_TIME, 10);
|
|
64
|
+
localHeader.writeUInt16LE(DOS_DATE, 12);
|
|
65
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
66
|
+
localHeader.writeUInt32LE(payload.length, 18);
|
|
67
|
+
localHeader.writeUInt32LE(data.length, 22);
|
|
68
|
+
localHeader.writeUInt16LE(nameBytes.length, 26);
|
|
69
|
+
localHeader.writeUInt16LE(0, 28); // extra length
|
|
70
|
+
|
|
71
|
+
localParts.push(localHeader, nameBytes, payload);
|
|
72
|
+
|
|
73
|
+
const centralHeader = Buffer.alloc(46);
|
|
74
|
+
centralHeader.writeUInt32LE(0x02014B50, 0);
|
|
75
|
+
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
76
|
+
centralHeader.writeUInt16LE(20, 6); // version needed
|
|
77
|
+
centralHeader.writeUInt16LE(0, 8); // flags
|
|
78
|
+
centralHeader.writeUInt16LE(method, 10);
|
|
79
|
+
centralHeader.writeUInt16LE(DOS_TIME, 12);
|
|
80
|
+
centralHeader.writeUInt16LE(DOS_DATE, 14);
|
|
81
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
82
|
+
centralHeader.writeUInt32LE(payload.length, 20);
|
|
83
|
+
centralHeader.writeUInt32LE(data.length, 24);
|
|
84
|
+
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
|
85
|
+
centralHeader.writeUInt16LE(0, 30); // extra length
|
|
86
|
+
centralHeader.writeUInt16LE(0, 32); // comment length
|
|
87
|
+
centralHeader.writeUInt16LE(0, 34); // disk number
|
|
88
|
+
centralHeader.writeUInt16LE(0, 36); // internal attrs
|
|
89
|
+
centralHeader.writeUInt32LE(0, 38); // external attrs
|
|
90
|
+
centralHeader.writeUInt32LE(offset, 42);
|
|
91
|
+
|
|
92
|
+
centralParts.push(centralHeader, nameBytes);
|
|
93
|
+
offset += localHeader.length + nameBytes.length + payload.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const centralDirectory = Buffer.concat(centralParts);
|
|
97
|
+
|
|
98
|
+
const endRecord = Buffer.alloc(22);
|
|
99
|
+
endRecord.writeUInt32LE(0x06054B50, 0);
|
|
100
|
+
endRecord.writeUInt16LE(0, 4); // disk number
|
|
101
|
+
endRecord.writeUInt16LE(0, 6); // central dir start disk
|
|
102
|
+
endRecord.writeUInt16LE(entries.length, 8);
|
|
103
|
+
endRecord.writeUInt16LE(entries.length, 10);
|
|
104
|
+
endRecord.writeUInt32LE(centralDirectory.length, 12);
|
|
105
|
+
endRecord.writeUInt32LE(offset, 16);
|
|
106
|
+
endRecord.writeUInt16LE(0, 20); // comment length
|
|
107
|
+
|
|
108
|
+
return Buffer.concat([...localParts, centralDirectory, endRecord]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const CONTENT_TYPES_BASE = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
112
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
113
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
114
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
115
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
116
|
+
%OVERRIDES%</Types>`;
|
|
117
|
+
|
|
118
|
+
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
119
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
120
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
121
|
+
</Relationships>`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Assembles a minimal .docx package around a word/document.xml payload.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} documentXml - Complete word/document.xml content
|
|
127
|
+
* @param {{ numberingXml?: string|null }} [parts] - Optional extra parts
|
|
128
|
+
* @returns {Buffer} - .docx bytes
|
|
129
|
+
*/
|
|
130
|
+
export function buildMinimalDocx(documentXml, parts = {}) {
|
|
131
|
+
const overrides = [];
|
|
132
|
+
const documentRels = [];
|
|
133
|
+
const entries = [];
|
|
134
|
+
|
|
135
|
+
if (parts.numberingXml) {
|
|
136
|
+
overrides.push(' <Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>\n');
|
|
137
|
+
documentRels.push(' <Relationship Id="rIdNum1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
entries.push({ name: '[Content_Types].xml', data: CONTENT_TYPES_BASE.replace('%OVERRIDES%', overrides.join('')) });
|
|
141
|
+
entries.push({ name: '_rels/.rels', data: ROOT_RELS });
|
|
142
|
+
entries.push({
|
|
143
|
+
name: 'word/_rels/document.xml.rels',
|
|
144
|
+
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
145
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
146
|
+
${documentRels.join('\n')}
|
|
147
|
+
</Relationships>`
|
|
148
|
+
});
|
|
149
|
+
entries.push({ name: 'word/document.xml', data: documentXml });
|
|
150
|
+
if (parts.numberingXml) {
|
|
151
|
+
entries.push({ name: 'word/numbering.xml', data: parts.numberingXml });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return buildZip(entries);
|
|
155
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Validates generated validation fixtures against the ECMA-376 transitional
|
|
3
|
+
# wordprocessingml XSD using xmllint. Used by the nightly validation workflow
|
|
4
|
+
# and runnable locally on any machine with curl, unzip, and xmllint.
|
|
5
|
+
#
|
|
6
|
+
# Usage: scripts/validate-fixtures-xsd.sh [fixtures-dir]
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
FIXTURES_DIR="${1:-tmp/validation-docx}"
|
|
10
|
+
CACHE_DIR="${OOXML_SCHEMA_DIR:-.cache/ooxml-schemas}"
|
|
11
|
+
ECMA_ZIP_URL="https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip"
|
|
12
|
+
XML_XSD_URL="https://www.w3.org/2001/xml.xsd"
|
|
13
|
+
|
|
14
|
+
command -v xmllint >/dev/null || { echo "xmllint not found (install libxml2-utils)"; exit 1; }
|
|
15
|
+
|
|
16
|
+
shopt -s nullglob
|
|
17
|
+
fixtures=("$FIXTURES_DIR"/*.document.xml)
|
|
18
|
+
if [ ${#fixtures[@]} -eq 0 ]; then
|
|
19
|
+
echo "No *.document.xml fixtures in $FIXTURES_DIR — run: node scripts/export-validation-fixtures.mjs"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if [ ! -f "$CACHE_DIR/wml.xsd" ]; then
|
|
24
|
+
echo "Downloading ECMA-376 Part 4 transitional schemas..."
|
|
25
|
+
mkdir -p "$CACHE_DIR"
|
|
26
|
+
curl -sSL --retry 3 -o "$CACHE_DIR/ecma376-4.zip" "$ECMA_ZIP_URL"
|
|
27
|
+
unzip -o -q "$CACHE_DIR/ecma376-4.zip" "OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
|
|
28
|
+
unzip -o -q "$CACHE_DIR/OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
|
|
29
|
+
curl -sSL --retry 3 -o "$CACHE_DIR/xml.xsd" "$XML_XSD_URL"
|
|
30
|
+
# ECMA's published XSDs import the xml namespace without a schemaLocation;
|
|
31
|
+
# point them at the local copy so xmllint can compile offline.
|
|
32
|
+
sed -i.bak 's|<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>|<xsd:import namespace="http://www.w3.org/XML/1998/namespace" schemaLocation="xml.xsd"/>|' "$CACHE_DIR"/*.xsd
|
|
33
|
+
rm -f "$CACHE_DIR"/*.xsd.bak "$CACHE_DIR/ecma376-4.zip"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
xmllint --noout --schema "$CACHE_DIR/wml.xsd" "${fixtures[@]}"
|
|
37
|
+
echo "XSD validation passed for ${#fixtures[@]} fixture(s)."
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$FixturesDir = "tmp/validation-docx"
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
# Differential validation against desktop Microsoft Word (the authoritative
|
|
6
|
+
# OOXML consumer). For each generated fixture, Word itself accepts all
|
|
7
|
+
# revisions and then rejects all revisions, and the resulting document text
|
|
8
|
+
# is compared to the expected outcomes recorded at generation time. This
|
|
9
|
+
# makes Word an independent oracle for the redline engine instead of
|
|
10
|
+
# verifying the library against its own accept/reject transforms.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# node scripts/export-validation-fixtures.mjs
|
|
14
|
+
# npm run smoke:word:diff
|
|
15
|
+
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
|
|
18
|
+
function Get-NormalizedText([string]$text) {
|
|
19
|
+
if ($null -eq $text) { return '' }
|
|
20
|
+
$text = $text -replace [string][char]7, ' ' # table cell markers
|
|
21
|
+
return ($text -replace '\s+', ' ').Trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$resolvedDir = Resolve-Path -LiteralPath $FixturesDir -ErrorAction SilentlyContinue
|
|
25
|
+
if (-not $resolvedDir) {
|
|
26
|
+
Write-Error "Fixtures directory '$FixturesDir' not found. Run: node scripts/export-validation-fixtures.mjs"
|
|
27
|
+
exit 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$expectations = Get-ChildItem -LiteralPath $resolvedDir -Filter '*.expected.json' | Sort-Object Name
|
|
31
|
+
if ($expectations.Count -eq 0) {
|
|
32
|
+
Write-Error "No *.expected.json fixtures in '$resolvedDir'. Run: node scripts/export-validation-fixtures.mjs"
|
|
33
|
+
exit 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$word = $null
|
|
37
|
+
$failures = 0
|
|
38
|
+
$results = @()
|
|
39
|
+
|
|
40
|
+
function Open-FixtureDocument($word, [string]$path) {
|
|
41
|
+
# Single-argument Open: Windows PowerShell 5.1 COM binding rejects the
|
|
42
|
+
# long optional-parameter signature. Defaults leave the document
|
|
43
|
+
# writable, which accept/reject requires; nothing is ever saved.
|
|
44
|
+
return $word.Documents.Open($path)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
$word = New-Object -ComObject Word.Application
|
|
49
|
+
$word.Visible = $false
|
|
50
|
+
$word.DisplayAlerts = 0 # wdAlertsNone
|
|
51
|
+
|
|
52
|
+
foreach ($expectationFile in $expectations) {
|
|
53
|
+
$name = $expectationFile.BaseName -replace '\.expected$', ''
|
|
54
|
+
$docxPath = Join-Path $resolvedDir "$name.docx"
|
|
55
|
+
if (-not (Test-Path -LiteralPath $docxPath)) {
|
|
56
|
+
Write-Warning "SKIP ${name}: no matching .docx"
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# -Encoding UTF8 is required: Windows PowerShell 5.1 otherwise reads
|
|
61
|
+
# BOM-less UTF-8 sidecars as ANSI and garbles non-ASCII expectations.
|
|
62
|
+
$expected = Get-Content -LiteralPath $expectationFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
63
|
+
$expectedAccepted = Get-NormalizedText $expected.expectedAcceptedText
|
|
64
|
+
$expectedRejected = Get-NormalizedText $expected.expectedRejectedText
|
|
65
|
+
$caseFailed = $false
|
|
66
|
+
$document = $null
|
|
67
|
+
|
|
68
|
+
# Phase 1: open cleanly, revisions present, accept-all matches intent.
|
|
69
|
+
try {
|
|
70
|
+
$document = Open-FixtureDocument $word ([string]$docxPath)
|
|
71
|
+
$revisionCount = $document.Revisions.Count
|
|
72
|
+
if ($revisionCount -lt 1) {
|
|
73
|
+
Write-Output "FAIL ${name}: Word sees no tracked revisions"
|
|
74
|
+
$caseFailed = $true
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
$document.AcceptAllRevisions()
|
|
78
|
+
$acceptedText = Get-NormalizedText $document.Content.Text
|
|
79
|
+
if ($acceptedText -ne $expectedAccepted) {
|
|
80
|
+
Write-Output "FAIL ${name}: accept-all mismatch"
|
|
81
|
+
Write-Output " expected: $expectedAccepted"
|
|
82
|
+
Write-Output " actual: $acceptedText"
|
|
83
|
+
$caseFailed = $true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
Write-Output "FAIL ${name}: Word could not open/accept: $($_.Exception.Message)"
|
|
89
|
+
$caseFailed = $true
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Phase 2: fresh open, reject-all restores the original text.
|
|
96
|
+
if (-not $caseFailed) {
|
|
97
|
+
try {
|
|
98
|
+
$document = Open-FixtureDocument $word ([string]$docxPath)
|
|
99
|
+
$document.RejectAllRevisions()
|
|
100
|
+
$rejectedText = Get-NormalizedText $document.Content.Text
|
|
101
|
+
if ($rejectedText -ne $expectedRejected) {
|
|
102
|
+
Write-Output "FAIL ${name}: reject-all mismatch"
|
|
103
|
+
Write-Output " expected: $expectedRejected"
|
|
104
|
+
Write-Output " actual: $rejectedText"
|
|
105
|
+
$caseFailed = $true
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
Write-Output "FAIL ${name}: Word could not open/reject: $($_.Exception.Message)"
|
|
110
|
+
$caseFailed = $true
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if ($caseFailed) {
|
|
118
|
+
$failures++
|
|
119
|
+
$results += "FAIL $name"
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
Write-Output "PASS ${name} (revisions: $revisionCount)"
|
|
123
|
+
$results += "PASS $name"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if ($null -ne $word) { $word.Quit() | Out-Null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Write-Output ""
|
|
132
|
+
Write-Output "Word differential: $($results.Count - $failures)/$($results.Count) fixtures passed."
|
|
133
|
+
if ($failures -gt 0) { exit 1 }
|