@ansonlai/docx-redline-js 0.1.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 +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createParser, createSerializer } from '../adapters/xml-adapter.js';
|
|
6
|
+
|
|
7
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
8
|
+
const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
9
|
+
const NS_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
10
|
+
const NUMBERING_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering';
|
|
11
|
+
const NUMBERING_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml';
|
|
12
|
+
const COMMENTS_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments';
|
|
13
|
+
const COMMENTS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml';
|
|
14
|
+
|
|
15
|
+
const DOCUMENT_PATH = 'word/document.xml';
|
|
16
|
+
const NUMBERING_PATH = 'word/numbering.xml';
|
|
17
|
+
const COMMENTS_PATH = 'word/comments.xml';
|
|
18
|
+
const CONTENT_TYPES_PATH = '[Content_Types].xml';
|
|
19
|
+
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
|
|
20
|
+
|
|
21
|
+
export function parseXmlStrictStandalone(xmlText, label = 'xml') {
|
|
22
|
+
const parser = createParser();
|
|
23
|
+
const xmlDoc = parser.parseFromString(xmlText, 'application/xml');
|
|
24
|
+
const parseError = xmlDoc.getElementsByTagName('parsererror')[0];
|
|
25
|
+
if (parseError) {
|
|
26
|
+
throw new Error(`[XML parse error] ${label}: ${parseError.textContent || 'Unknown'}`);
|
|
27
|
+
}
|
|
28
|
+
return xmlDoc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isSectionPropertiesElement(node) {
|
|
32
|
+
return !!node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'sectPr';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getBodyElementFromDocument(xmlDoc) {
|
|
36
|
+
return xmlDoc.getElementsByTagNameNS('*', 'body')[0] || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDirectSectionProperties(body) {
|
|
40
|
+
for (const child of Array.from(body.childNodes || [])) {
|
|
41
|
+
if (isSectionPropertiesElement(child)) return child;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function insertBodyElementBeforeSectPr(body, element) {
|
|
47
|
+
const sectPr = getDirectSectionProperties(body);
|
|
48
|
+
if (sectPr) {
|
|
49
|
+
body.insertBefore(element, sectPr);
|
|
50
|
+
} else {
|
|
51
|
+
body.appendChild(element);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeBodySectionOrderStandalone(xmlDoc) {
|
|
56
|
+
const body = getBodyElementFromDocument(xmlDoc);
|
|
57
|
+
if (!body) return;
|
|
58
|
+
const sectPr = getDirectSectionProperties(body);
|
|
59
|
+
if (!sectPr) return;
|
|
60
|
+
let cursor = sectPr.nextSibling;
|
|
61
|
+
while (cursor) {
|
|
62
|
+
const next = cursor.nextSibling;
|
|
63
|
+
if (cursor.nodeType === 1) {
|
|
64
|
+
body.insertBefore(cursor, sectPr);
|
|
65
|
+
}
|
|
66
|
+
cursor = next;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Flattens nested table-cell paragraphs (`w:tc > w:p > w:p`) by promoting
|
|
72
|
+
* inner paragraphs to direct `w:tc` children.
|
|
73
|
+
*
|
|
74
|
+
* @param {Document} xmlDoc
|
|
75
|
+
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
76
|
+
* @returns {number} number of nested paragraphs fixed
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeNestedParagraphsInTables(xmlDoc, options = {}) {
|
|
79
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
80
|
+
const tcs = xmlDoc.getElementsByTagNameNS(NS_W, 'tc');
|
|
81
|
+
let fixed = 0;
|
|
82
|
+
for (const tc of Array.from(tcs)) {
|
|
83
|
+
const outerParagraphs = Array.from(tc.childNodes || []).filter(
|
|
84
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
85
|
+
);
|
|
86
|
+
for (const outerParagraph of outerParagraphs) {
|
|
87
|
+
const innerParagraphs = Array.from(outerParagraph.childNodes || []).filter(
|
|
88
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
89
|
+
);
|
|
90
|
+
for (const innerParagraph of innerParagraphs) {
|
|
91
|
+
tc.insertBefore(innerParagraph, outerParagraph);
|
|
92
|
+
fixed += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (fixed > 0) {
|
|
97
|
+
onInfo(`[Sanitize] Fixed ${fixed} nested w:p element(s) in table cells`);
|
|
98
|
+
}
|
|
99
|
+
return fixed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getPackagePartName(partElement) {
|
|
103
|
+
return partElement.getAttribute('pkg:name') || partElement.getAttribute('name') || '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractFromPackageXml(packageXml) {
|
|
107
|
+
const parser = createParser();
|
|
108
|
+
const serializer = createSerializer();
|
|
109
|
+
const pkgDoc = parser.parseFromString(packageXml, 'application/xml');
|
|
110
|
+
const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
|
|
111
|
+
const documentPart = parts.find(part => getPackagePartName(part) === '/word/document.xml');
|
|
112
|
+
if (!documentPart) {
|
|
113
|
+
throw new Error('Package output missing /word/document.xml part');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const xmlData = documentPart.getElementsByTagNameNS('*', 'xmlData')[0];
|
|
117
|
+
if (!xmlData) {
|
|
118
|
+
throw new Error('Package document part missing pkg:xmlData');
|
|
119
|
+
}
|
|
120
|
+
const documentNode = Array.from(xmlData.childNodes || []).find(node => node.nodeType === 1);
|
|
121
|
+
if (!documentNode) {
|
|
122
|
+
throw new Error('Package document part missing XML payload');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const body = documentNode.getElementsByTagNameNS('*', 'body')[0];
|
|
126
|
+
const replacementNodes = body
|
|
127
|
+
? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
|
|
128
|
+
: [documentNode];
|
|
129
|
+
|
|
130
|
+
const numberingPart = parts.find(part => getPackagePartName(part) === '/word/numbering.xml');
|
|
131
|
+
let numberingXml = null;
|
|
132
|
+
if (numberingPart) {
|
|
133
|
+
const numberingXmlData = numberingPart.getElementsByTagNameNS('*', 'xmlData')[0];
|
|
134
|
+
const numberingNode = numberingXmlData
|
|
135
|
+
? Array.from(numberingXmlData.childNodes || []).find(node => node.nodeType === 1)
|
|
136
|
+
: null;
|
|
137
|
+
if (numberingNode) {
|
|
138
|
+
numberingXml = serializer.serializeToString(numberingNode);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
replacementNodes,
|
|
144
|
+
numberingXml,
|
|
145
|
+
sourceType: 'package'
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extracts replacement nodes and optional numbering payload from engine output.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} outputOxml
|
|
153
|
+
* @returns {{ replacementNodes: Element[], numberingXml: string|null, sourceType: 'package'|'document'|'fragment' }}
|
|
154
|
+
*/
|
|
155
|
+
export function extractReplacementNodesFromOoxml(outputOxml) {
|
|
156
|
+
if (typeof outputOxml !== 'string' || !outputOxml.trim()) {
|
|
157
|
+
throw new Error('Reconciliation engine returned no OOXML payload for this operation');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (outputOxml.includes('<pkg:package')) {
|
|
161
|
+
return extractFromPackageXml(outputOxml);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (outputOxml.includes('<w:document')) {
|
|
165
|
+
const parser = createParser();
|
|
166
|
+
const doc = parser.parseFromString(outputOxml, 'application/xml');
|
|
167
|
+
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
|
168
|
+
const replacementNodes = body
|
|
169
|
+
? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
|
|
170
|
+
: Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
|
|
171
|
+
return { replacementNodes, numberingXml: null, sourceType: 'document' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
|
|
175
|
+
const parser = createParser();
|
|
176
|
+
const fragmentDoc = parser.parseFromString(wrapped, 'application/xml');
|
|
177
|
+
const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
|
|
178
|
+
return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function upsertContentTypeOverride(ctDoc, partName, contentType) {
|
|
182
|
+
const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
|
|
183
|
+
const hasOverride = overrides.some(
|
|
184
|
+
override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
|
|
185
|
+
);
|
|
186
|
+
if (hasOverride) return false;
|
|
187
|
+
|
|
188
|
+
const override = ctDoc.createElementNS(NS_CT, 'Override');
|
|
189
|
+
override.setAttribute('PartName', partName);
|
|
190
|
+
override.setAttribute('ContentType', contentType);
|
|
191
|
+
ctDoc.documentElement.appendChild(override);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function upsertDocumentRelationship(relsDoc, relType, target) {
|
|
196
|
+
const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
|
|
197
|
+
const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
|
|
198
|
+
const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
|
|
199
|
+
if (hasRel) return false;
|
|
200
|
+
|
|
201
|
+
let maxId = 0;
|
|
202
|
+
for (const rel of rels) {
|
|
203
|
+
const idValue = rel.getAttribute('Id') || '';
|
|
204
|
+
const idNum = Number.parseInt(idValue.replace(/^rId/i, ''), 10);
|
|
205
|
+
if (Number.isFinite(idNum)) {
|
|
206
|
+
maxId = Math.max(maxId, idNum);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
|
|
211
|
+
rel.setAttribute('Id', `rId${maxId + 1}`);
|
|
212
|
+
rel.setAttribute('Type', relType);
|
|
213
|
+
rel.setAttribute('Target', target);
|
|
214
|
+
relsRoot.appendChild(rel);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readZipText(zip, filePath) {
|
|
219
|
+
const entry = zip.file(filePath);
|
|
220
|
+
if (!entry) return null;
|
|
221
|
+
return entry.async('string');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ensures numbering part + package metadata exist and merges numbering payloads.
|
|
226
|
+
*
|
|
227
|
+
* @param {any} zip
|
|
228
|
+
* @param {string|string[]|null|undefined} numberingXmlList
|
|
229
|
+
* @param {{
|
|
230
|
+
* mergeNumberingXml?: ((existingXml: string, incomingXml: string) => string),
|
|
231
|
+
* onInfo?: (message: string) => void
|
|
232
|
+
* }} [options]
|
|
233
|
+
*/
|
|
234
|
+
export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, options = {}) {
|
|
235
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
236
|
+
const mergeNumberingXml = typeof options?.mergeNumberingXml === 'function'
|
|
237
|
+
? options.mergeNumberingXml
|
|
238
|
+
: null;
|
|
239
|
+
const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
|
|
240
|
+
if (incomingPayloads.length === 0) return;
|
|
241
|
+
|
|
242
|
+
const existing = await readZipText(zip, NUMBERING_PATH);
|
|
243
|
+
let mergedNumberingXml = existing || null;
|
|
244
|
+
for (const incomingNumberingXml of incomingPayloads) {
|
|
245
|
+
if (!mergedNumberingXml) {
|
|
246
|
+
mergedNumberingXml = incomingNumberingXml;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
mergedNumberingXml = mergeNumberingXml
|
|
250
|
+
? mergeNumberingXml(mergedNumberingXml, incomingNumberingXml)
|
|
251
|
+
: incomingNumberingXml;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!existing) {
|
|
255
|
+
onInfo('[Demo] Adding numbering.xml');
|
|
256
|
+
} else {
|
|
257
|
+
onInfo('[Demo] Merging numbering.xml payload(s) into existing numbering definitions');
|
|
258
|
+
}
|
|
259
|
+
zip.file(NUMBERING_PATH, mergedNumberingXml);
|
|
260
|
+
|
|
261
|
+
const parser = createParser();
|
|
262
|
+
const serializer = createSerializer();
|
|
263
|
+
|
|
264
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
265
|
+
if (ctText) {
|
|
266
|
+
const ctDoc = parser.parseFromString(ctText, 'application/xml');
|
|
267
|
+
if (upsertContentTypeOverride(ctDoc, '/word/numbering.xml', NUMBERING_CONTENT_TYPE)) {
|
|
268
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
273
|
+
if (relsText) {
|
|
274
|
+
const relsDoc = parser.parseFromString(relsText, 'application/xml');
|
|
275
|
+
if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml')) {
|
|
276
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Ensures comments part + package metadata exist and merges incoming comments.
|
|
283
|
+
*
|
|
284
|
+
* @param {any} zip
|
|
285
|
+
* @param {string|null|undefined} commentsXml
|
|
286
|
+
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
287
|
+
*/
|
|
288
|
+
export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
|
|
289
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
290
|
+
if (!commentsXml) return;
|
|
291
|
+
|
|
292
|
+
const parser = createParser();
|
|
293
|
+
const serializer = createSerializer();
|
|
294
|
+
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
295
|
+
if (!existingText) {
|
|
296
|
+
onInfo('[Demo] Adding comments.xml');
|
|
297
|
+
zip.file(COMMENTS_PATH, commentsXml);
|
|
298
|
+
} else {
|
|
299
|
+
const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
|
|
300
|
+
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
301
|
+
const existingRoot = existingDoc.documentElement;
|
|
302
|
+
const existingIds = new Set(
|
|
303
|
+
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
304
|
+
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
305
|
+
.filter(Boolean)
|
|
306
|
+
);
|
|
307
|
+
for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
|
|
308
|
+
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
309
|
+
if (id && existingIds.has(id)) {
|
|
310
|
+
throw new Error(`Duplicate comment id: ${id}`);
|
|
311
|
+
}
|
|
312
|
+
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
313
|
+
}
|
|
314
|
+
zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
318
|
+
if (ctText) {
|
|
319
|
+
const ctDoc = parser.parseFromString(ctText, 'application/xml');
|
|
320
|
+
if (upsertContentTypeOverride(ctDoc, '/word/comments.xml', COMMENTS_CONTENT_TYPE)) {
|
|
321
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
326
|
+
if (relsText) {
|
|
327
|
+
const relsDoc = parser.parseFromString(relsText, 'application/xml');
|
|
328
|
+
if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml')) {
|
|
329
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Validates core package integrity for document/comments/numbering artifacts.
|
|
336
|
+
*
|
|
337
|
+
* @param {any} zip
|
|
338
|
+
*/
|
|
339
|
+
export async function validateDocxPackage(zip) {
|
|
340
|
+
const documentXml = await readZipText(zip, DOCUMENT_PATH);
|
|
341
|
+
if (!documentXml) {
|
|
342
|
+
throw new Error('Validation failed: missing word/document.xml');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const documentDoc = parseXmlStrictStandalone(documentXml, DOCUMENT_PATH);
|
|
346
|
+
normalizeBodySectionOrderStandalone(documentDoc);
|
|
347
|
+
const body = getBodyElementFromDocument(documentDoc);
|
|
348
|
+
if (!body) {
|
|
349
|
+
throw new Error('Validation failed: word/document.xml has no w:body');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const directBodyElements = Array.from(body.childNodes || []).filter(node => node.nodeType === 1);
|
|
353
|
+
const sectPrIndexes = directBodyElements
|
|
354
|
+
.map((node, index) => ({ node, index }))
|
|
355
|
+
.filter(entry => isSectionPropertiesElement(entry.node))
|
|
356
|
+
.map(entry => entry.index);
|
|
357
|
+
|
|
358
|
+
if (sectPrIndexes.length > 1) {
|
|
359
|
+
throw new Error('Validation failed: multiple body-level w:sectPr');
|
|
360
|
+
}
|
|
361
|
+
if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== directBodyElements.length - 1) {
|
|
362
|
+
throw new Error('Validation failed: w:sectPr not last');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const tcs = documentDoc.getElementsByTagNameNS(NS_W, 'tc');
|
|
366
|
+
for (const tc of Array.from(tcs)) {
|
|
367
|
+
for (const child of Array.from(tc.childNodes || []).filter(node => node.nodeType === 1)) {
|
|
368
|
+
if (child.namespaceURI === NS_W && child.localName === 'p') {
|
|
369
|
+
const hasNestedParagraph = Array.from(child.childNodes || []).some(
|
|
370
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
371
|
+
);
|
|
372
|
+
if (hasNestedParagraph) {
|
|
373
|
+
throw new Error('Validation failed: nested w:p');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const hasNumberingUsage = documentDoc.getElementsByTagNameNS(NS_W, 'numPr').length > 0;
|
|
380
|
+
const hasCommentUsage =
|
|
381
|
+
documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeStart').length > 0
|
|
382
|
+
|| documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeEnd').length > 0
|
|
383
|
+
|| documentDoc.getElementsByTagNameNS(NS_W, 'commentReference').length > 0;
|
|
384
|
+
|
|
385
|
+
const numberingXml = await readZipText(zip, NUMBERING_PATH);
|
|
386
|
+
const commentsXml = await readZipText(zip, COMMENTS_PATH);
|
|
387
|
+
|
|
388
|
+
if (numberingXml) {
|
|
389
|
+
parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
|
|
390
|
+
} else if (hasNumberingUsage) {
|
|
391
|
+
throw new Error('Validation failed: numbering used but part missing');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (commentsXml) {
|
|
395
|
+
parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
|
|
396
|
+
} else if (hasCommentUsage) {
|
|
397
|
+
throw new Error('Validation failed: comments used but part missing');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const ctXml = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
401
|
+
if (!ctXml) {
|
|
402
|
+
throw new Error(`Validation failed: missing ${CONTENT_TYPES_PATH}`);
|
|
403
|
+
}
|
|
404
|
+
const ctDoc = parseXmlStrictStandalone(ctXml, CONTENT_TYPES_PATH);
|
|
405
|
+
|
|
406
|
+
const relsXml = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
407
|
+
if (!relsXml) {
|
|
408
|
+
throw new Error(`Validation failed: missing ${DOCUMENT_RELS_PATH}`);
|
|
409
|
+
}
|
|
410
|
+
const relsDoc = parseXmlStrictStandalone(relsXml, DOCUMENT_RELS_PATH);
|
|
411
|
+
|
|
412
|
+
if (numberingXml) {
|
|
413
|
+
const hasNumberingContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
|
|
414
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/numbering.xml'
|
|
415
|
+
&& (override.getAttribute('ContentType') || '') === NUMBERING_CONTENT_TYPE
|
|
416
|
+
);
|
|
417
|
+
const hasNumberingRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
418
|
+
(rel.getAttribute('Type') || '') === NUMBERING_REL_TYPE
|
|
419
|
+
);
|
|
420
|
+
if (!hasNumberingContentType) {
|
|
421
|
+
throw new Error('Validation failed: numbering CT override missing');
|
|
422
|
+
}
|
|
423
|
+
if (!hasNumberingRel) {
|
|
424
|
+
throw new Error('Validation failed: numbering rel missing');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (commentsXml) {
|
|
429
|
+
const hasCommentsContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
|
|
430
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/comments.xml'
|
|
431
|
+
&& (override.getAttribute('ContentType') || '') === COMMENTS_CONTENT_TYPE
|
|
432
|
+
);
|
|
433
|
+
const hasCommentsRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
434
|
+
(rel.getAttribute('Type') || '') === COMMENTS_REL_TYPE
|
|
435
|
+
);
|
|
436
|
+
if (!hasCommentsContentType) {
|
|
437
|
+
throw new Error('Validation failed: comments CT override missing');
|
|
438
|
+
}
|
|
439
|
+
if (!hasCommentsRel) {
|
|
440
|
+
throw new Error('Validation failed: comments rel missing');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|