@ansonlai/docx-redline-js 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1148 -572
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +95 -104
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. package/services/table-reconciliation.js +23 -11
@@ -0,0 +1,271 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ const pythonVenvExe = join(repoRoot, 'tmp', 'visual-qa-venv', 'Scripts', 'python.exe');
8
+
9
+ export function loadManifest(manifestPath) {
10
+ if (!existsSync(manifestPath)) {
11
+ return null;
12
+ }
13
+ let content = readFileSync(manifestPath, 'utf8');
14
+ if (content.charCodeAt(0) === 0xFEFF) {
15
+ content = content.slice(1);
16
+ }
17
+ return JSON.parse(content);
18
+ }
19
+
20
+ export function inspectManifest(manifest, baseDir) {
21
+ const results = {
22
+ totalCases: 0,
23
+ renderedCases: 0,
24
+ passedCases: 0,
25
+ anomalies: [],
26
+ caseSummaries: []
27
+ };
28
+
29
+ if (!manifest || !Array.isArray(manifest.cases)) {
30
+ return results;
31
+ }
32
+
33
+ results.totalCases = manifest.cases.length;
34
+
35
+ for (const testCase of manifest.cases) {
36
+ const caseName = testCase.name || testCase.identity;
37
+ const views = testCase.views || {};
38
+ let caseRendered = true;
39
+ let caseValid = true;
40
+ const viewDetails = {};
41
+
42
+ for (const viewName of ['allMarkup', 'acceptAll', 'rejectAll']) {
43
+ const viewInfo = views[viewName];
44
+ if (!viewInfo || viewInfo.status !== 'rendered') {
45
+ caseRendered = false;
46
+ caseValid = false;
47
+ continue;
48
+ }
49
+
50
+ const pdfFileName = viewInfo.pdf;
51
+ const pdfPath = join(baseDir, pdfFileName);
52
+ const exists = existsSync(pdfPath);
53
+ const pages = viewInfo.pages;
54
+ const bytes = viewInfo.bytes;
55
+
56
+ viewDetails[viewName] = { exists, pages, bytes };
57
+
58
+ if (!exists) {
59
+ results.anomalies.push({
60
+ caseName,
61
+ view: viewName,
62
+ reason: `Missing PDF file: ${pdfFileName}`
63
+ });
64
+ caseValid = false;
65
+ } else if (!bytes || bytes < 1000) {
66
+ results.anomalies.push({
67
+ caseName,
68
+ view: viewName,
69
+ reason: `Suspiciously small PDF size: ${bytes} bytes`
70
+ });
71
+ caseValid = false;
72
+ } else if (!pages || pages < 1) {
73
+ results.anomalies.push({
74
+ caseName,
75
+ view: viewName,
76
+ reason: `Invalid page count: ${pages}`
77
+ });
78
+ caseValid = false;
79
+ }
80
+ }
81
+
82
+ if (caseRendered) {
83
+ results.renderedCases++;
84
+ }
85
+
86
+ // Layout sanity check between acceptAll and rejectAll
87
+ const acceptPages = viewDetails.acceptAll?.pages;
88
+ const rejectPages = viewDetails.rejectAll?.pages;
89
+ if (acceptPages !== undefined && rejectPages !== undefined) {
90
+ const pageDiff = Math.abs(acceptPages - rejectPages);
91
+ if (pageDiff > 2 && acceptPages > 0 && rejectPages > 0) {
92
+ results.anomalies.push({
93
+ caseName,
94
+ reason: `Unusual page count disparity: acceptAll=${acceptPages} vs rejectAll=${rejectPages}`
95
+ });
96
+ caseValid = false;
97
+ }
98
+ }
99
+
100
+ if (caseValid) {
101
+ results.passedCases++;
102
+ }
103
+
104
+ results.caseSummaries.push({
105
+ name: caseName,
106
+ status: caseValid ? 'valid' : 'flagged',
107
+ rendered: caseRendered,
108
+ views: viewDetails
109
+ });
110
+ }
111
+
112
+ return results;
113
+ }
114
+
115
+ export function generateContactSheetWithPyMuPdf(pdfPath, outputPngPath, maxPages = 20) {
116
+ if (!existsSync(pythonVenvExe)) {
117
+ return false;
118
+ }
119
+
120
+ const pythonScript = `
121
+ import sys
122
+ import fitz # PyMuPDF
123
+ from PIL import Image
124
+
125
+ pdf_path = sys.argv[1]
126
+ output_png = sys.argv[2]
127
+ max_pages = int(sys.argv[3]) if len(sys.argv) > 3 else 20
128
+
129
+ doc = fitz.open(pdf_path)
130
+ page_count = min(len(doc), max_pages)
131
+
132
+ # Render pages as pixmaps
133
+ images = []
134
+ for i in range(page_count):
135
+ page = doc.load_page(i)
136
+ pix = page.get_pixmap(dpi=72)
137
+ img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
138
+ images.append(img)
139
+
140
+ doc.close()
141
+
142
+ if not images:
143
+ sys.exit(1)
144
+
145
+ # Tile into contact sheet grid (up to 5 columns)
146
+ cols = min(5, len(images))
147
+ rows = (len(images) + cols - 1) // cols
148
+ page_w, page_h = images[0].size
149
+ sheet = Image.new("RGB", (cols * page_w, rows * page_h), color=(240, 240, 240))
150
+
151
+ for idx, img in enumerate(images):
152
+ c = idx % cols
153
+ r = idx // cols
154
+ sheet.paste(img, (c * page_w, r * page_h))
155
+
156
+ sheet.save(output_png)
157
+ print("Saved contact sheet:", output_png)
158
+ `;
159
+
160
+ try {
161
+ execFileSync(pythonVenvExe, ['-c', pythonScript, pdfPath, outputPngPath, String(maxPages)], {
162
+ stdio: 'pipe',
163
+ encoding: 'utf8'
164
+ });
165
+ return true;
166
+ } catch (e) {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function runCli() {
172
+ console.log('=== Word Visual Evidence Inspection ===\n');
173
+
174
+ const syntheticDir = join(repoRoot, 'tmp', 'word-visual-review', 'rendered');
175
+ const syntheticManifestPath = join(syntheticDir, 'manifest.json');
176
+ const corpusDir = join(repoRoot, 'tmp', 'superdoc-word-visual-review', 'rendered');
177
+ const corpusManifestPath = join(corpusDir, 'manifest.json');
178
+
179
+ let totalCases = 0;
180
+ let totalRendered = 0;
181
+ let totalValid = 0;
182
+ let totalAnomalies = 0;
183
+
184
+ // Check synthetic lane
185
+ const syntheticManifest = loadManifest(syntheticManifestPath);
186
+ if (syntheticManifest) {
187
+ const syntheticResults = inspectManifest(syntheticManifest, syntheticDir);
188
+ console.log(`Synthetic Word Visual Evidence (${syntheticDir}):`);
189
+ console.log(` Word Version: ${syntheticManifest.word?.version || 'unknown'} (Build: ${syntheticManifest.word?.build || 'unknown'})`);
190
+ console.log(` Total Cases: ${syntheticResults.totalCases}`);
191
+ console.log(` Rendered Cases: ${syntheticResults.renderedCases}`);
192
+ console.log(` Valid Cases: ${syntheticResults.passedCases}`);
193
+ if (syntheticResults.anomalies.length > 0) {
194
+ console.log(' Anomalies:');
195
+ for (const a of syntheticResults.anomalies) {
196
+ console.log(` - [${a.caseName}] ${a.view ? a.view + ': ' : ''}${a.reason}`);
197
+ }
198
+ }
199
+ console.log('');
200
+ totalCases += syntheticResults.totalCases;
201
+ totalRendered += syntheticResults.renderedCases;
202
+ totalValid += syntheticResults.passedCases;
203
+ totalAnomalies += syntheticResults.anomalies.length;
204
+ } else {
205
+ console.log(`Synthetic lane manifest not found at ${syntheticManifestPath}\n Run: npm run test:word:visual\n`);
206
+ }
207
+
208
+ // Check corpus lane
209
+ const corpusManifest = loadManifest(corpusManifestPath);
210
+ if (corpusManifest) {
211
+ const corpusResults = inspectManifest(corpusManifest, corpusDir);
212
+ console.log(`SuperDoc Real-Document Visual Evidence (${corpusDir}):`);
213
+ console.log(` Word Version: ${corpusManifest.word?.version || 'unknown'} (Build: ${corpusManifest.word?.build || 'unknown'})`);
214
+ console.log(` Total Cases: ${corpusResults.totalCases}`);
215
+ console.log(` Rendered Cases: ${corpusResults.renderedCases}`);
216
+ console.log(` Valid Cases: ${corpusResults.passedCases}`);
217
+ if (corpusResults.anomalies.length > 0) {
218
+ console.log(' Anomalies:');
219
+ for (const a of corpusResults.anomalies) {
220
+ console.log(` - [${a.caseName}] ${a.view ? a.view + ': ' : ''}${a.reason}`);
221
+ }
222
+ }
223
+ console.log('');
224
+ totalCases += corpusResults.totalCases;
225
+ totalRendered += corpusResults.renderedCases;
226
+ totalValid += corpusResults.passedCases;
227
+ totalAnomalies += corpusResults.anomalies.length;
228
+ } else {
229
+ console.log(`SuperDoc corpus manifest not found at ${corpusManifestPath}\n Run: npm run test:corpus:word:visual\n`);
230
+ }
231
+
232
+ console.log(`Summary: ${totalRendered}/${totalCases} rendered, ${totalValid} valid, ${totalAnomalies} anomalies.\n`);
233
+
234
+ if (process.argv.includes('--contact-sheets')) {
235
+ console.log('Generating visual review contact sheets...');
236
+ const sheetsDir = join(repoRoot, 'tmp', 'word-visual-review', 'inspected-sheets');
237
+ mkdirSync(sheetsDir, { recursive: true });
238
+
239
+ let generated = 0;
240
+ if (syntheticManifest) {
241
+ for (const c of syntheticManifest.cases.slice(0, 10)) {
242
+ for (const [viewName, viewInfo] of Object.entries(c.views)) {
243
+ const pdfPath = join(syntheticDir, viewInfo.pdf);
244
+ const outPath = join(sheetsDir, `synthetic--${c.name}--${viewName}.png`);
245
+ if (existsSync(pdfPath)) {
246
+ const success = generateContactSheetWithPyMuPdf(pdfPath, outPath, 10);
247
+ if (success) generated++;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ if (corpusManifest) {
253
+ for (const c of corpusManifest.cases.slice(0, 10)) {
254
+ for (const [viewName, viewInfo] of Object.entries(c.views)) {
255
+ const pdfPath = join(corpusDir, viewInfo.pdf);
256
+ const outPath = join(sheetsDir, `superdoc--${c.scenarioKey || c.name}--${viewName}.png`);
257
+ if (existsSync(pdfPath)) {
258
+ const success = generateContactSheetWithPyMuPdf(pdfPath, outPath, 10);
259
+ if (success) generated++;
260
+ }
261
+ }
262
+ }
263
+ }
264
+ console.log(`Generated ${generated} visual contact sheets in ${sheetsDir}`);
265
+ }
266
+ }
267
+
268
+ const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
269
+ if (isCli) {
270
+ runCli();
271
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { deflateRawSync } from 'zlib';
11
+ import { DOMParser } from '@xmldom/xmldom';
11
12
 
12
13
  const CRC_TABLE = (() => {
13
14
  const table = new Uint32Array(256);
@@ -120,36 +121,216 @@ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
120
121
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
121
122
  </Relationships>`;
122
123
 
124
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
125
+ const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
126
+ const REL_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
127
+ const CONTENT_TYPES = {
128
+ numbering: 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml',
129
+ comments: 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml',
130
+ footnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml',
131
+ endnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml',
132
+ header: 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml',
133
+ footer: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml'
134
+ };
135
+
136
+ function escapeXmlAttribute(value) {
137
+ return String(value)
138
+ .replace(/&/g, '&amp;')
139
+ .replace(/"/g, '&quot;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;');
142
+ }
143
+
144
+ function parsePartXml(xml, label) {
145
+ const errors = [];
146
+ const document = new DOMParser({
147
+ onError: (_level, message) => errors.push(message)
148
+ }).parseFromString(String(xml), 'application/xml');
149
+ if (!document?.documentElement || errors.length > 0 || document.getElementsByTagName('parsererror').length > 0) {
150
+ throw new Error(`${label} is not well-formed XML${errors[0] ? `: ${errors[0]}` : ''}`);
151
+ }
152
+ return document;
153
+ }
154
+
155
+ function wordIds(document, localName) {
156
+ return Array.from(document.getElementsByTagNameNS(NS_W, localName), node =>
157
+ node.getAttributeNS?.(NS_W, 'id') || node.getAttribute('w:id') || node.getAttribute('id')
158
+ );
159
+ }
160
+
161
+ function relationshipIds(document, localName) {
162
+ return Array.from(document.getElementsByTagNameNS(NS_W, localName), node =>
163
+ node.getAttributeNS?.(NS_R, 'id') || node.getAttribute('r:id') || node.getAttribute('id')
164
+ ).filter(Boolean);
165
+ }
166
+
167
+ function requireRoot(document, localName, label) {
168
+ if (document.documentElement.namespaceURI !== NS_W || document.documentElement.localName !== localName) {
169
+ throw new Error(`${label} must have w:${localName} as its document element`);
170
+ }
171
+ }
172
+
173
+ function validateReferencedIds(documentXml, parts) {
174
+ const document = parsePartXml(documentXml, 'word/document.xml');
175
+ requireRoot(document, 'document', 'word/document.xml');
176
+
177
+ const documentCommentIds = [
178
+ ...wordIds(document, 'commentRangeStart'),
179
+ ...wordIds(document, 'commentRangeEnd'),
180
+ ...wordIds(document, 'commentReference')
181
+ ];
182
+ if (documentCommentIds.length > 0 && !parts.commentsXml) {
183
+ throw new Error('document comment anchors require word/comments.xml');
184
+ }
185
+ if (parts.commentsXml) {
186
+ const comments = parsePartXml(parts.commentsXml, 'word/comments.xml');
187
+ requireRoot(comments, 'comments', 'word/comments.xml');
188
+ const defined = new Set(wordIds(comments, 'comment'));
189
+ const starts = wordIds(document, 'commentRangeStart');
190
+ const ends = wordIds(document, 'commentRangeEnd');
191
+ const references = wordIds(document, 'commentReference');
192
+ for (const id of new Set([...starts, ...ends, ...references])) {
193
+ if (!defined.has(id)) throw new Error(`word/comments.xml does not define referenced comment ID ${id}`);
194
+ }
195
+ if (starts.length === 0 || JSON.stringify(starts) !== JSON.stringify(ends) || JSON.stringify(starts) !== JSON.stringify(references)) {
196
+ throw new Error('comment start/end/reference IDs must be present and ordered identically');
197
+ }
198
+ }
199
+
200
+ for (const family of ['footnote', 'endnote']) {
201
+ const partKey = `${family}sXml`;
202
+ const referenceIds = wordIds(document, `${family}Reference`);
203
+ if (referenceIds.length > 0 && !parts[partKey]) {
204
+ throw new Error(`document ${family} references require word/${family}s.xml`);
205
+ }
206
+ if (!parts[partKey]) continue;
207
+ const partName = `word/${family}s.xml`;
208
+ const notes = parsePartXml(parts[partKey], partName);
209
+ requireRoot(notes, `${family}s`, partName);
210
+ const defined = new Set(wordIds(notes, family));
211
+ if (!defined.has('-1') || !defined.has('0')) {
212
+ throw new Error(`${partName} must define separator ID -1 and continuation separator ID 0`);
213
+ }
214
+ for (const id of referenceIds) {
215
+ if (!defined.has(id)) throw new Error(`${partName} does not define referenced ${family} ID ${id}`);
216
+ }
217
+ }
218
+
219
+ const headerIds = new Set((parts.headers || []).map(header => header.relationshipId));
220
+ const footerIds = new Set((parts.footers || []).map(footer => footer.relationshipId));
221
+ for (const id of relationshipIds(document, 'headerReference')) {
222
+ if (!headerIds.has(id)) throw new Error(`document headerReference ${id} has no configured header relationship`);
223
+ }
224
+ for (const id of relationshipIds(document, 'footerReference')) {
225
+ if (!footerIds.has(id)) throw new Error(`document footerReference ${id} has no configured footer relationship`);
226
+ }
227
+
228
+ const hyperlinkIds = new Set((parts.externalHyperlinks || []).map(link => link.relationshipId));
229
+ for (const id of relationshipIds(document, 'hyperlink')) {
230
+ if (!hyperlinkIds.has(id)) throw new Error(`external hyperlink ${id} has no configured relationship`);
231
+ }
232
+ }
233
+
234
+ function normalizeRelatedParts(parts) {
235
+ const normalized = {
236
+ ...parts,
237
+ headers: (parts.headers || []).map((header, index) => ({
238
+ partName: header.partName || `header${index + 1}.xml`,
239
+ relationshipId: header.relationshipId || `rIdHeader${index + 1}`,
240
+ xml: header.xml
241
+ })),
242
+ footers: (parts.footers || []).map((footer, index) => ({
243
+ partName: footer.partName || `footer${index + 1}.xml`,
244
+ relationshipId: footer.relationshipId || `rIdFooter${index + 1}`,
245
+ xml: footer.xml
246
+ })),
247
+ externalHyperlinks: parts.externalHyperlinks || []
248
+ };
249
+
250
+ const relationshipIds = [];
251
+ if (normalized.numberingXml) relationshipIds.push('rIdNum1');
252
+ if (normalized.commentsXml) relationshipIds.push('rIdComments1');
253
+ if (normalized.footnotesXml) relationshipIds.push('rIdFootnotes1');
254
+ if (normalized.endnotesXml) relationshipIds.push('rIdEndnotes1');
255
+ relationshipIds.push(...normalized.headers.map(item => item.relationshipId));
256
+ relationshipIds.push(...normalized.footers.map(item => item.relationshipId));
257
+ relationshipIds.push(...normalized.externalHyperlinks.map(item => item.relationshipId));
258
+ if (relationshipIds.some(id => !/^rId[A-Za-z0-9._-]+$/.test(String(id)))) {
259
+ throw new Error('every relationship ID must use a non-empty rId-prefixed token');
260
+ }
261
+ if (new Set(relationshipIds).size !== relationshipIds.length) throw new Error('document relationship IDs must be unique');
262
+
263
+ for (const [family, items, pattern] of [
264
+ ['header', normalized.headers, /^header[1-9][0-9]*\.xml$/],
265
+ ['footer', normalized.footers, /^footer[1-9][0-9]*\.xml$/]
266
+ ]) {
267
+ for (const item of items) {
268
+ if (!pattern.test(item.partName)) throw new Error(`${family} part name must match ${family}<number>.xml`);
269
+ if (typeof item.xml !== 'string' || item.xml.length === 0) throw new Error(`${family} ${item.partName} requires XML content`);
270
+ const xml = parsePartXml(item.xml, `word/${item.partName}`);
271
+ requireRoot(xml, family === 'header' ? 'hdr' : 'ftr', `word/${item.partName}`);
272
+ }
273
+ }
274
+ const partNames = [...normalized.headers, ...normalized.footers].map(item => item.partName);
275
+ if (new Set(partNames).size !== partNames.length) throw new Error('header/footer part names must be unique');
276
+
277
+ for (const link of normalized.externalHyperlinks) {
278
+ if (!link.relationshipId || !link.target) throw new Error('external hyperlinks require relationshipId and target');
279
+ let url;
280
+ try { url = new URL(link.target); } catch { throw new Error(`invalid external hyperlink target: ${link.target}`); }
281
+ if (!url.protocol || url.protocol === 'file:') throw new Error(`unsupported external hyperlink target: ${link.target}`);
282
+ }
283
+ return normalized;
284
+ }
285
+
123
286
  /**
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
287
+ * Creates and validates the deterministic entry set used by buildMinimalDocx.
288
+ * Exported for package-integrity tests; this remains script-only tooling.
129
289
  */
130
- export function buildMinimalDocx(documentXml, parts = {}) {
290
+ export function buildMinimalDocxEntries(documentXml, parts = {}) {
291
+ const normalized = normalizeRelatedParts(parts);
292
+ validateReferencedIds(documentXml, normalized);
131
293
  const overrides = [];
132
294
  const documentRels = [];
133
295
  const entries = [];
134
296
 
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
- }
297
+ const addXmlPart = (partName, contentType, relationshipId, relationshipType, xml) => {
298
+ overrides.push(` <Override PartName="/word/${partName}" ContentType="${contentType}"/>\n`);
299
+ documentRels.push(` <Relationship Id="${escapeXmlAttribute(relationshipId)}" Type="${REL_BASE}${relationshipType}" Target="${escapeXmlAttribute(partName)}"/>`);
300
+ entries.push({ name: `word/${partName}`, data: xml });
301
+ };
139
302
 
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({
303
+ if (normalized.numberingXml) addXmlPart('numbering.xml', CONTENT_TYPES.numbering, 'rIdNum1', 'numbering', normalized.numberingXml);
304
+ if (normalized.commentsXml) addXmlPart('comments.xml', CONTENT_TYPES.comments, 'rIdComments1', 'comments', normalized.commentsXml);
305
+ if (normalized.footnotesXml) addXmlPart('footnotes.xml', CONTENT_TYPES.footnotes, 'rIdFootnotes1', 'footnotes', normalized.footnotesXml);
306
+ if (normalized.endnotesXml) addXmlPart('endnotes.xml', CONTENT_TYPES.endnotes, 'rIdEndnotes1', 'endnotes', normalized.endnotesXml);
307
+ normalized.headers.forEach(item => addXmlPart(item.partName, CONTENT_TYPES.header, item.relationshipId, 'header', item.xml));
308
+ normalized.footers.forEach(item => addXmlPart(item.partName, CONTENT_TYPES.footer, item.relationshipId, 'footer', item.xml));
309
+ normalized.externalHyperlinks.forEach(link => {
310
+ documentRels.push(` <Relationship Id="${escapeXmlAttribute(link.relationshipId)}" Type="${REL_BASE}hyperlink" Target="${escapeXmlAttribute(link.target)}" TargetMode="External"/>`);
311
+ });
312
+
313
+ entries.unshift({ name: 'word/document.xml', data: documentXml });
314
+ entries.unshift({
143
315
  name: 'word/_rels/document.xml.rels',
144
316
  data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
145
317
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
146
318
  ${documentRels.join('\n')}
147
319
  </Relationships>`
148
320
  });
149
- entries.push({ name: 'word/document.xml', data: documentXml });
150
- if (parts.numberingXml) {
151
- entries.push({ name: 'word/numbering.xml', data: parts.numberingXml });
152
- }
321
+ entries.unshift({ name: '_rels/.rels', data: ROOT_RELS });
322
+ entries.unshift({ name: '[Content_Types].xml', data: CONTENT_TYPES_BASE.replace('%OVERRIDES%', overrides.join('')) });
323
+ return entries;
324
+ }
153
325
 
154
- return buildZip(entries);
326
+ /**
327
+ * Assembles a minimal .docx package around a word/document.xml payload.
328
+ *
329
+ * @param {string} documentXml - Complete word/document.xml content
330
+ * @param {Object} [parts] - Optional numbering/comments/notes/header/footer/
331
+ * external-hyperlink package parts. This helper is development-only.
332
+ * @returns {Buffer} - .docx bytes
333
+ */
334
+ export function buildMinimalDocx(documentXml, parts = {}) {
335
+ return buildZip(buildMinimalDocxEntries(documentXml, parts));
155
336
  }