@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.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1148 -572
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +95 -104
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +17 -13
- package/engine/surgical-diff-application.js +7 -21
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +23 -5
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- 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, '&')
|
|
139
|
+
.replace(/"/g, '"')
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>');
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
136
|
-
overrides.push(
|
|
137
|
-
documentRels.push(
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
}
|