@ansonlai/docx-redline-js 0.1.6 → 0.2.1

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.
@@ -5,7 +5,7 @@
5
5
  * and `w:pPrChange` elements used by surgical and reconstruction modes.
6
6
  */
7
7
 
8
- import { RPR_SCHEMA_ORDER } from './rpr-helpers.js';
8
+ import { extractFormatFromRPr, RPR_SCHEMA_ORDER } from './rpr-helpers.js';
9
9
  import { createRevisionMetadata } from '../core/types.js';
10
10
  import { getFirstElementByTag } from '../core/xml-query.js';
11
11
  import { createWordElement } from '../core/word-xml.js';
@@ -140,8 +140,8 @@ export function createTextRun(xmlDoc, text, rPr, isDelete) {
140
140
  * @param {boolean} [generateRedlines] - Whether to create rPrChange
141
141
  * @returns {Element[]}
142
142
  */
143
- export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
144
- if (!text) return [];
143
+ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
144
+ if (!text) return [];
145
145
 
146
146
  const breaks = new Set([0, text.length]);
147
147
  for (const hint of formatHints) {
@@ -167,13 +167,17 @@ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffs
167
167
  h.start <= segmentBaseOffset && h.end >= segmentEndOffset
168
168
  );
169
169
 
170
- const combinedFormat = {};
171
- applicableHints.forEach(h => {
172
- if (h.format) Object.assign(combinedFormat, h.format);
173
- });
174
-
175
- const formattedRPr = injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines);
176
- runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
170
+ const combinedFormat = { ...extractFormatFromRPr(baseRPr) };
171
+ applicableHints.forEach(h => {
172
+ if (h.format) Object.assign(combinedFormat, h.format);
173
+ });
174
+
175
+ // During a text edit, missing Markdown is not an instruction to clear
176
+ // the source run's formatting. Only synchronize explicitly hinted spans.
177
+ const formattedRPr = applicableHints.length > 0
178
+ ? injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines)
179
+ : baseRPr?.cloneNode(true) || null;
180
+ runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
177
181
  }
178
182
 
179
183
  return runs;
@@ -1,5 +1,4 @@
1
1
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
2
- import { isWordElement } from '../core/word-xml.js';
3
2
  import {
4
3
  createTrackChange,
5
4
  createTextRun,
@@ -20,30 +19,17 @@ import {
20
19
  findLastSpanEndingBeforeOrAt,
21
20
  forEachOverlappingSpan
22
21
  } from './surgical-spans.js';
22
+ import { extractFormatFromRPr } from './rpr-helpers.js';
23
23
 
24
24
  export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
25
- const desiredFormat = {};
26
- if (applicableHints.length > 0) {
27
- applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
28
- }
25
+ // Plain modified text carries no negative formatting instruction. Preserve
26
+ // unchanged source formatting unless Markdown explicitly targets this span.
27
+ if (applicableHints.length === 0) return false;
29
28
 
30
29
  const rPr = span.rPr;
31
- const hasElement = (localName) => {
32
- if (!rPr) return false;
33
- for (let node = rPr.firstChild; node; node = node.nextSibling) {
34
- if (isWordElement(node, localName)) {
35
- return true;
36
- }
37
- }
38
- return false;
39
- };
40
-
41
- const existingFormat = {
42
- bold: hasElement('b'),
43
- italic: hasElement('i'),
44
- underline: hasElement('u'),
45
- strikethrough: hasElement('strike')
46
- };
30
+ const existingFormat = extractFormatFromRPr(rPr);
31
+ const desiredFormat = { ...existingFormat };
32
+ applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
47
33
 
48
34
  const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
49
35
  const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
package/index.d.ts CHANGED
@@ -126,6 +126,30 @@ export function rejectTrackedChangesInOoxml(oxml: string, options?: RevisionFilt
126
126
  export function deleteCommentsByAuthorInOoxml(oxml: string, options?: RevisionFilterOptions): DeleteCommentsResult;
127
127
  export function containsTrackedChanges(xmlDoc: Document | Element): boolean;
128
128
 
129
+ export type RedlineValidationSeverity = 'error' | 'warning';
130
+
131
+ export interface RedlineValidationIssue {
132
+ code:
133
+ | 'PARSE_ERROR'
134
+ | 'NESTED_REVISION'
135
+ | 'DEL_CONTAINS_T'
136
+ | 'MISSING_REVISION_METADATA'
137
+ | 'DUPLICATE_REVISION_ID'
138
+ | 'MISSING_SPACE_PRESERVE'
139
+ | 'EMPTY_TEXT_ELEMENT'
140
+ | 'EMPTY_REVISION_WRAPPER'
141
+ | string;
142
+ severity: RedlineValidationSeverity;
143
+ message: string;
144
+ }
145
+
146
+ export interface RedlineValidationResult {
147
+ valid: boolean;
148
+ issues: RedlineValidationIssue[];
149
+ }
150
+
151
+ export function validateRedlineOoxml(oxml: string): RedlineValidationResult;
152
+
129
153
  export function applyHighlightToOoxml(oxml: string, targetText: string, color: string, options?: Record<string, unknown>): string;
130
154
  export function generateTableOoxml(headersOrData: unknown, rowsOrOptions?: unknown, options?: Record<string, unknown>): string;
131
155
  export function extractReplacementNodesFromOoxml(oxml: string): unknown;
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.1",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,13 +51,14 @@
51
51
  }
52
52
  },
53
53
  "devDependencies": {
54
- "esbuild": "^0.24.0",
55
- "@xmldom/xmldom": "^0.9.0"
54
+ "@xmldom/xmldom": "^0.9.0",
55
+ "esbuild": "^0.28.1"
56
56
  },
57
57
  "scripts": {
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)."