@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/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
- * Standalone-safe redline wrapper.
33
- *
34
- * In non-Word runtimes, the engine can return `{ useNativeApi: true, hasChanges: true }`
35
- * without an OOXML payload for some format-only operations. Standalone callers cannot
36
- * complete that native fallback path, so normalize to a no-op with warnings.
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.1.6",
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": ">=18.0.0"
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 pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
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: ['./index.js'],
13
+ entryPoints: [entryPoint],
9
14
  bundle: true,
10
15
  format: 'esm',
11
- outfile: 'dist/docx-redline-js.esm.js',
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: ['./index.js'],
29
+ entryPoints: [entryPoint],
25
30
  bundle: true,
26
31
  format: 'esm',
27
- outfile: 'dist/docx-redline-js.esm.min.js',
32
+ outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
28
33
  platform: 'neutral',
29
34
  target: 'es2020',
30
35
  minify: true,
@@ -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
- documentXml: baseDocument('The old sentence.'),
26
- operation: { type: 'redline', target: 'The old sentence.', modified: 'The new sentence.' }
28
+ original: 'The old sentence.',
29
+ modified: 'The new sentence.'
27
30
  },
28
31
  {
29
32
  name: 'paragraph-insert',
30
- documentXml: baseDocument('one'),
31
- operation: { type: 'redline', target: 'one', modified: 'one\ntwo' }
33
+ original: 'one',
34
+ modified: 'one\ntwo'
32
35
  },
33
36
  {
34
37
  name: 'format-only',
35
- documentXml: baseDocument('Make word bold'),
36
- operation: { type: 'redline', target: 'Make word bold', modified: 'Make **word** bold' }
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.documentXml,
43
- testCase.operation,
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
- This folder contains generated OOXML parts for release-time validation.
101
+ Generated by \`node scripts/export-validation-fixtures.mjs\`.
102
+
103
+ Each case produces:
57
104
 
58
- The script writes document XML rather than complete .docx packages because this
59
- package intentionally does not add a zip dependency.
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
- To manually inspect these fixtures:
112
+ Validation entry points:
62
113
 
63
- 1. Copy a generated *.document.xml file into a minimal .docx package as word/document.xml.
64
- 2. Include any matching *.numbering.xml as word/numbering.xml.
65
- 3. Open with Word, LibreOffice, or another OOXML consumer.
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 }