@stll/folio-core 0.64.1 → 0.66.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/dist/ai-edits/apply.js +31 -9
- package/dist/ai-edits/headless.js +3 -3
- package/dist/compare/reproducible-package.d.ts +5 -0
- package/dist/compare/reproducible-package.js +10 -23
- package/dist/docx/archiveInflation.d.ts +8 -103
- package/dist/docx/archiveInflation.js +8 -145
- package/dist/docx/attributeRemainder.js +1 -1
- package/dist/docx/canonicalStoryRepack.js +2 -2
- package/dist/docx/documentSectionFacts.js +1 -1
- package/dist/docx/ensureParaIds.js +9 -24
- package/dist/docx/importedIdCollisions.js +1 -1
- package/dist/docx/mathToMathml.js +1 -1
- package/dist/docx/metadataPrivacy.js +38 -48
- package/dist/docx/numericIdNormalization.js +2 -2
- package/dist/docx/packageParts.d.ts +4 -4
- package/dist/docx/packageParts.js +17 -24
- package/dist/docx/parser.js +5 -6
- package/dist/docx/removeHeaderFooterParts.d.ts +2 -2
- package/dist/docx/removeHeaderFooterParts.js +19 -36
- package/dist/docx/revisionIdNormalization.js +2 -2
- package/dist/docx/rezip.d.ts +7 -7
- package/dist/docx/rezip.js +144 -532
- package/dist/docx/sdtProperties.js +1 -2
- package/dist/docx/selectedXmlIdentityScan.d.ts +1 -1
- package/dist/docx/selectedXmlIdentityScan.js +1 -1
- package/dist/docx/selectiveSave.js +37 -41
- package/dist/docx/selectiveSaveFlags.d.ts +1 -1
- package/dist/docx/selectiveSaveTripwire.js +5 -11
- package/dist/docx/selectiveXmlPatch.js +2 -2
- package/dist/docx/server/applyDocxXmlPatchProposal.js +20 -41
- package/dist/docx/server/boundedArchive.d.ts +2 -3
- package/dist/docx/server/boundedArchive.js +78 -99
- package/dist/docx/server/inspectDocxPackage.d.ts +1 -1
- package/dist/docx/server/inspectDocxPackage.js +1 -1
- package/dist/docx/server/validateDocxConformance.js +1 -1
- package/dist/docx/shapeAlternateContent.js +1 -1
- package/dist/docx/storyBlockReplay.js +1 -1
- package/dist/docx/unzip.d.ts +2 -3
- package/dist/docx/unzip.js +127 -155
- package/dist/docx/verbatimCapture.js +1 -1
- package/dist/docx/xmlParser.d.ts +5 -36
- package/dist/docx/xmlParser.js +14 -78
- package/dist/docx/xmlSafety.js +5 -5
- package/dist/layout-painter/renderPage.js +4 -2
- package/dist/markdown/countParagraphList.d.ts +18 -0
- package/dist/markdown/countParagraphList.js +90 -0
- package/dist/markdown/renderParagraph.js +2 -62
- package/dist/markdown/renderRuns.js +4 -1
- package/dist/prosemirror/conversion/fromProseDoc.js +5 -3
- package/dist/prosemirror/conversion/toProseDoc.js +4 -1
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +4 -1
- package/dist/prosemirror/replacedAnnotations.d.ts +6 -0
- package/dist/prosemirror/replacedAnnotations.js +30 -8
- package/dist/prosemirror/schema/nodes.d.ts +7 -0
- package/dist/prosemirror/textBoxAnchorAttrs.js +9 -1
- package/package.json +3 -5
- package/dist/docx/streamingXmlParser.d.ts +0 -60
- package/dist/docx/streamingXmlParser.js +0 -406
- package/dist/docx/xmlNamespaceContext.d.ts +0 -6
- package/dist/docx/xmlNamespaceContext.js +0 -53
- package/dist/docx/xmlResourceLimits.d.ts +0 -107
- package/dist/docx/xmlResourceLimits.js +0 -194
package/dist/docx/unzip.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { bytesToDataUrl } from "../utils/base64.js";
|
|
2
|
-
import {
|
|
3
|
-
import { compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isStoredZipEntry } from "./archiveInflation.js";
|
|
2
|
+
import { DOCX_COMPRESSION_RATIO_GRACE_BYTES, isRatioBoundedPart } from "./archiveInflation.js";
|
|
4
3
|
import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
|
|
5
4
|
import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
|
|
6
5
|
import { RASTER_MIME_TYPES, detectRasterMimeType } from "./rasterMime.js";
|
|
7
6
|
import { decodeXmlBytes } from "./xmlEncoding.js";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
7
|
+
import { Result } from "better-result";
|
|
8
|
+
import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "@stll/docx-core/xml/limits";
|
|
9
|
+
import { ZipArchive, ZipError } from "@stll/docx-core/zip";
|
|
11
10
|
// src/docx/unzip.ts
|
|
12
11
|
/**
|
|
13
12
|
* DOCX Unzipper
|
|
@@ -116,12 +115,10 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
|
|
|
116
115
|
const zipBuffer = containerType === DOCX_CONTAINER_TYPES.ZIP ? buffer : await openDocxBuffer(buffer, { password: options.password });
|
|
117
116
|
if (zipBuffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
|
|
118
117
|
const wasEncrypted = containerType === DOCX_CONTAINER_TYPES.CFB;
|
|
119
|
-
const
|
|
120
|
-
if (countCentralDirectoryRecords(new Uint8Array(zipBuffer), maxRecords) > maxRecords) throw new DocxSecurityError("DOCX file contains too many entries");
|
|
121
|
-
const loaded = await loadDocxZip(zipBuffer, limits.maxFiles);
|
|
118
|
+
const loaded = await loadDocxZip(zipBuffer, limits);
|
|
122
119
|
if (loaded.buffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
|
|
123
120
|
const { zip } = loaded;
|
|
124
|
-
const entries =
|
|
121
|
+
const entries = zip.entries();
|
|
125
122
|
if (entries.length > limits.maxFiles) throw new DocxSecurityError("DOCX file contains too many entries");
|
|
126
123
|
const content = {
|
|
127
124
|
documentXml: null,
|
|
@@ -152,79 +149,46 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
|
|
|
152
149
|
warnings: [],
|
|
153
150
|
wasEncrypted
|
|
154
151
|
};
|
|
155
|
-
let totalUncompressedBytes = 0;
|
|
156
|
-
const inflationBudget = createInflationBudget(limits.maxTotalUncompressedBytes);
|
|
157
152
|
const extractionTasks = [];
|
|
158
|
-
const verifyEntry = (file
|
|
159
|
-
if (!verifyUnreadEntries
|
|
153
|
+
const verifyEntry = (file) => {
|
|
154
|
+
if (!verifyUnreadEntries) return;
|
|
160
155
|
extractionTasks.push(async () => {
|
|
161
|
-
await
|
|
162
|
-
file,
|
|
163
|
-
path,
|
|
164
|
-
limits,
|
|
165
|
-
budget: inflationBudget
|
|
166
|
-
});
|
|
156
|
+
await verifyEntryData(file);
|
|
167
157
|
return null;
|
|
168
158
|
});
|
|
169
159
|
};
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
maxEntryBytes,
|
|
173
|
-
maxCompressionRatio: compressionRatioLimitFor(file.name, limits.maxCompressionRatio),
|
|
174
|
-
budget: inflationBudget
|
|
175
|
-
});
|
|
176
|
-
for (const [path, file] of entries) {
|
|
177
|
-
if (!isSafeDocxPath(path)) throw new DocxSecurityError("DOCX file contains an unsafe entry path");
|
|
178
|
-
const { compressedBytes, uncompressedBytes: declaredSize } = getZipEntrySizes(file);
|
|
179
|
-
if (declaredSize !== null) {
|
|
180
|
-
totalUncompressedBytes += declaredSize;
|
|
181
|
-
if (totalUncompressedBytes > limits.maxTotalUncompressedBytes) throw new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
|
|
182
|
-
if (exceedsCompressionRatio({
|
|
183
|
-
inflatedBytes: declaredSize,
|
|
184
|
-
compressedBytes,
|
|
185
|
-
maxRatio: compressionRatioLimitFor(path, limits.maxCompressionRatio)
|
|
186
|
-
})) throw new DocxSecurityError(`DOCX entry exceeds the maximum compression ratio: ${path}`);
|
|
187
|
-
}
|
|
160
|
+
for (const file of entries) {
|
|
161
|
+
const { path, size: declaredSize } = file;
|
|
188
162
|
if (!isParsedDocxEntry(path)) {
|
|
189
|
-
verifyEntry(file
|
|
163
|
+
verifyEntry(file);
|
|
190
164
|
continue;
|
|
191
165
|
}
|
|
192
166
|
const lowerPath = path.toLowerCase();
|
|
193
167
|
if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) {
|
|
194
168
|
assertEntrySize(path, declaredSize, limits.maxXmlBytes);
|
|
195
169
|
if (options.extractAllXml === false && !shouldExtractXmlPart(lowerPath)) {
|
|
196
|
-
verifyEntry(file
|
|
170
|
+
verifyEntry(file);
|
|
197
171
|
continue;
|
|
198
172
|
}
|
|
199
|
-
extractionTasks.push(async () => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
lowerPath,
|
|
206
|
-
content: decodeXmlBytes(result.bytes)
|
|
207
|
-
};
|
|
208
|
-
});
|
|
173
|
+
extractionTasks.push(async () => ({
|
|
174
|
+
type: "xml",
|
|
175
|
+
path,
|
|
176
|
+
lowerPath,
|
|
177
|
+
content: decodeXmlBytes(await readEntry(file))
|
|
178
|
+
}));
|
|
209
179
|
} else if (lowerPath.startsWith("word/media/")) {
|
|
210
180
|
const mimeType = getMediaMimeType(path);
|
|
211
181
|
if (!limits.allowedMediaMimeTypes.has(mimeType)) {
|
|
212
|
-
verifyEntry(file
|
|
182
|
+
verifyEntry(file);
|
|
213
183
|
continue;
|
|
214
184
|
}
|
|
215
185
|
if (isEntryTooLarge(declaredSize, limits.maxMediaBytes)) {
|
|
216
186
|
content.warnings.push(`Skipped oversized media file: ${path}; original entry preserved for round-trip.`);
|
|
217
|
-
verifyEntry(file
|
|
187
|
+
verifyEntry(file);
|
|
218
188
|
continue;
|
|
219
189
|
}
|
|
220
190
|
extractionTasks.push(async () => {
|
|
221
|
-
const
|
|
222
|
-
if (!result.ok) {
|
|
223
|
-
if (result.limit !== "entry") throw inflationLimitError(result.limit, path);
|
|
224
|
-
content.warnings.push(`Skipped oversized media file: ${path}; original entry preserved for round-trip.`);
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
const binaryContent = result.bytes.buffer;
|
|
191
|
+
const binaryContent = (await readEntry(file)).buffer;
|
|
228
192
|
const effectiveMimeType = RASTER_MIME_TYPES.has(mimeType) ? detectRasterMimeType(binaryContent) : mimeType;
|
|
229
193
|
if (!effectiveMimeType || !limits.allowedMediaMimeTypes.has(effectiveMimeType) || !isMediaContentAllowed(binaryContent, effectiveMimeType)) return null;
|
|
230
194
|
return {
|
|
@@ -236,30 +200,18 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
|
|
|
236
200
|
});
|
|
237
201
|
} else if (lowerPath.startsWith("word/fonts/")) {
|
|
238
202
|
if (isEntryTooLarge(declaredSize, limits.maxFontBytes)) {
|
|
239
|
-
verifyEntry(file
|
|
203
|
+
verifyEntry(file);
|
|
240
204
|
continue;
|
|
241
205
|
}
|
|
242
|
-
extractionTasks.push(async () => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
type: "font",
|
|
250
|
-
path,
|
|
251
|
-
content: result.bytes.buffer
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
} else verifyEntry(file, path);
|
|
206
|
+
extractionTasks.push(async () => ({
|
|
207
|
+
type: "font",
|
|
208
|
+
path,
|
|
209
|
+
content: (await readEntry(file)).buffer
|
|
210
|
+
}));
|
|
211
|
+
} else verifyEntry(file);
|
|
255
212
|
}
|
|
256
|
-
if (exceedsCompressionRatio({
|
|
257
|
-
inflatedBytes: totalUncompressedBytes,
|
|
258
|
-
compressedBytes: loaded.buffer.byteLength,
|
|
259
|
-
maxRatio: limits.maxCompressionRatio
|
|
260
|
-
})) throw new DocxSecurityError("DOCX file exceeds the maximum compression ratio");
|
|
261
213
|
const xmlBudget = createXmlPackageBudget();
|
|
262
|
-
for (const extracted of await runExtractionTasks(extractionTasks
|
|
214
|
+
for (const extracted of await runExtractionTasks(extractionTasks)) {
|
|
263
215
|
if (!extracted) continue;
|
|
264
216
|
if (extracted.type === "xml") {
|
|
265
217
|
assignXmlContent(content, extracted, limits, xmlBudget);
|
|
@@ -284,18 +236,10 @@ const replaceRawDocxXmlParts = async (content, parts) => {
|
|
|
284
236
|
lowerPath: path.toLowerCase(),
|
|
285
237
|
content: xmlContent
|
|
286
238
|
});
|
|
287
|
-
|
|
288
|
-
zip: content.originalZip,
|
|
289
|
-
path,
|
|
290
|
-
data: encodeUtf8(xmlContent)
|
|
291
|
-
});
|
|
239
|
+
content.originalZip.set(path, xmlContent);
|
|
292
240
|
identitiesChanged = true;
|
|
293
241
|
}
|
|
294
|
-
if (identitiesChanged) content.originalBuffer = await content.originalZip.
|
|
295
|
-
type: "arraybuffer",
|
|
296
|
-
compression: "DEFLATE",
|
|
297
|
-
compressionOptions: { level: 1 }
|
|
298
|
-
});
|
|
242
|
+
if (identitiesChanged) content.originalBuffer = (await content.originalZip.generate({ level: 1 })).buffer;
|
|
299
243
|
};
|
|
300
244
|
/**
|
|
301
245
|
* Preflight every XML part the unzip retains, not a named few.
|
|
@@ -349,80 +293,111 @@ const indexXmlContent = (content, { path, lowerPath, content: xmlContent }) => {
|
|
|
349
293
|
content.footers.set(filename, xmlContent);
|
|
350
294
|
}
|
|
351
295
|
};
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
case "
|
|
357
|
-
case "
|
|
358
|
-
case "
|
|
296
|
+
/** The error the unzip reports for an archive or entry the reader refused. */
|
|
297
|
+
function docxErrorFor(error) {
|
|
298
|
+
const path = error.path ?? "";
|
|
299
|
+
switch (error.reason) {
|
|
300
|
+
case "declared-size-exceeded": return new DocxSecurityError(`DOCX entry inflates past its declared size: ${path}`);
|
|
301
|
+
case "compression-ratio": return new DocxSecurityError(error.path === void 0 ? "DOCX file exceeds the maximum compression ratio" : `DOCX entry exceeds the maximum compression ratio: ${path}`);
|
|
302
|
+
case "too-many-entries": return new DocxSecurityError("DOCX file contains too many entries");
|
|
303
|
+
case "entry-too-large":
|
|
304
|
+
case "total-too-large": return new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
|
|
305
|
+
case "invalid-entry-name": return new DocxSecurityError("DOCX file contains an unsafe entry path");
|
|
306
|
+
case "malformed":
|
|
307
|
+
case "unsupported":
|
|
308
|
+
case "duplicate-entry":
|
|
309
|
+
case "corrupt-entry": return error;
|
|
359
310
|
}
|
|
360
311
|
}
|
|
312
|
+
const rethrowRead = (error) => {
|
|
313
|
+
throw error instanceof ZipError ? docxErrorFor(error) : error;
|
|
314
|
+
};
|
|
315
|
+
/** An extracted entry's bytes; inflation stops at its declared size. */
|
|
316
|
+
async function readEntry(file) {
|
|
317
|
+
const read = await Result.tryPromise({
|
|
318
|
+
try: async () => await file.bytes(),
|
|
319
|
+
catch: (cause) => cause
|
|
320
|
+
});
|
|
321
|
+
return Result.isOk(read) ? read.value : rethrowRead(read.error);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Inflate an entry without keeping it, to bound what a later read of it can
|
|
325
|
+
* produce. Data past the declared size refuses the package. A corrupt entry
|
|
326
|
+
* does not: it was never going to be read here, a later read fails the same
|
|
327
|
+
* way, and refusing the whole document over a part folio does not model
|
|
328
|
+
* would be a regression.
|
|
329
|
+
*/
|
|
330
|
+
async function verifyEntryData(file) {
|
|
331
|
+
const read = await Result.tryPromise({
|
|
332
|
+
try: async () => await file.bytes(),
|
|
333
|
+
catch: (cause) => cause
|
|
334
|
+
});
|
|
335
|
+
if (Result.isOk(read)) return;
|
|
336
|
+
const error = read.error;
|
|
337
|
+
if (!(error instanceof ZipError) || error.reason === "declared-size-exceeded") rethrowRead(error);
|
|
338
|
+
}
|
|
361
339
|
/**
|
|
362
340
|
* Run extraction tasks a few at a time, in order, keeping their results in
|
|
363
|
-
* task order. The first failure
|
|
364
|
-
* the streams still running at their next chunk and leaves the queued tasks
|
|
365
|
-
* unstarted.
|
|
341
|
+
* task order. The first failure leaves the queued tasks unstarted.
|
|
366
342
|
*/
|
|
367
|
-
async function runExtractionTasks(tasks
|
|
343
|
+
async function runExtractionTasks(tasks) {
|
|
368
344
|
const results = Array.from({ length: tasks.length }, () => null);
|
|
369
345
|
let next = 0;
|
|
346
|
+
let aborted = false;
|
|
370
347
|
const worker = async () => {
|
|
371
|
-
while (next < tasks.length && !
|
|
348
|
+
while (next < tasks.length && !aborted) {
|
|
372
349
|
const index = next;
|
|
373
350
|
next += 1;
|
|
374
351
|
const task = tasks[index];
|
|
375
352
|
if (!task) return;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
353
|
+
const result = await Result.tryPromise({
|
|
354
|
+
try: async () => await task(),
|
|
355
|
+
catch: (cause) => cause
|
|
356
|
+
});
|
|
357
|
+
if (Result.isError(result)) {
|
|
358
|
+
aborted = true;
|
|
359
|
+
throw result.error;
|
|
381
360
|
}
|
|
361
|
+
results[index] = result.value;
|
|
382
362
|
}
|
|
383
363
|
};
|
|
384
364
|
await Promise.all(Array.from({ length: Math.min(EXTRACTION_CONCURRENCY, tasks.length) }, async () => await worker()));
|
|
385
365
|
return results;
|
|
386
366
|
}
|
|
387
|
-
/**
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
};
|
|
422
|
-
} catch {
|
|
423
|
-
throw error;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
367
|
+
/** Package limits as the archive reader enforces them. */
|
|
368
|
+
const zipReadOptionsFor = (limits) => ({
|
|
369
|
+
limits: {
|
|
370
|
+
maxEntries: limits.maxFiles * 2,
|
|
371
|
+
maxEntryBytes: limits.maxTotalUncompressedBytes,
|
|
372
|
+
maxTotalBytes: limits.maxTotalUncompressedBytes,
|
|
373
|
+
maxCompressionRatio: limits.maxCompressionRatio,
|
|
374
|
+
compressionRatioGraceBytes: DOCX_COMPRESSION_RATIO_GRACE_BYTES
|
|
375
|
+
},
|
|
376
|
+
isCompressionRatioExempt: (path) => !isRatioBoundedPart(path)
|
|
377
|
+
});
|
|
378
|
+
async function loadDocxZip(buffer, limits) {
|
|
379
|
+
const options = zipReadOptionsFor(limits);
|
|
380
|
+
const read = await Result.tryPromise({
|
|
381
|
+
try: async () => await ZipArchive.read(buffer, options),
|
|
382
|
+
catch: (cause) => cause
|
|
383
|
+
});
|
|
384
|
+
if (Result.isOk(read)) return {
|
|
385
|
+
zip: read.value,
|
|
386
|
+
buffer
|
|
387
|
+
};
|
|
388
|
+
const error = read.error;
|
|
389
|
+
if (!(error instanceof ZipError) || error.reason !== "malformed") return rethrowRead(error);
|
|
390
|
+
const repairedBuffer = repairTruncatedEndOfCentralDirectory(buffer, limits.maxFiles);
|
|
391
|
+
if (!repairedBuffer) throw error;
|
|
392
|
+
const repaired = await Result.tryPromise({
|
|
393
|
+
try: async () => await ZipArchive.read(repairedBuffer, options),
|
|
394
|
+
catch: (cause) => cause
|
|
395
|
+
});
|
|
396
|
+
if (Result.isOk(repaired)) return {
|
|
397
|
+
zip: repaired.value,
|
|
398
|
+
buffer: repairedBuffer
|
|
399
|
+
};
|
|
400
|
+
throw error;
|
|
426
401
|
}
|
|
427
402
|
function repairTruncatedEndOfCentralDirectory(buffer, maxFiles) {
|
|
428
403
|
const bytes = new Uint8Array(buffer);
|
|
@@ -520,14 +495,11 @@ function isParsedDocxEntry(path) {
|
|
|
520
495
|
if (!(lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels"))) return false;
|
|
521
496
|
return lowerPath === "[content_types].xml" || lowerPath.startsWith("_rels/") || lowerPath.startsWith("docprops/") || lowerPath.startsWith("word/") || lowerPath.startsWith("customxml/");
|
|
522
497
|
}
|
|
523
|
-
function getEntryUncompressedSize(file) {
|
|
524
|
-
return getZipEntrySizes(file).uncompressedBytes;
|
|
525
|
-
}
|
|
526
498
|
function assertEntrySize(path, declaredSize, maxBytes) {
|
|
527
499
|
if (isEntryTooLarge(declaredSize, maxBytes)) throw new DocxSecurityError(`DOCX entry exceeds maximum size: ${path}`);
|
|
528
500
|
}
|
|
529
501
|
function isEntryTooLarge(declaredSize, maxBytes) {
|
|
530
|
-
return declaredSize
|
|
502
|
+
return declaredSize > maxBytes;
|
|
531
503
|
}
|
|
532
504
|
function isMediaContentAllowed(data, mimeType) {
|
|
533
505
|
if (RASTER_MIME_TYPES.has(mimeType)) return detectRasterMimeType(data) === mimeType;
|
|
@@ -548,7 +520,7 @@ function isMediaContentAllowed(data, mimeType) {
|
|
|
548
520
|
*/
|
|
549
521
|
function getFileList(content) {
|
|
550
522
|
const files = [];
|
|
551
|
-
for (const path of
|
|
523
|
+
for (const path of content.originalZip.paths()) if (isParsedDocxEntry(path)) files.push(path);
|
|
552
524
|
return files.toSorted();
|
|
553
525
|
}
|
|
554
526
|
/**
|
|
@@ -591,11 +563,11 @@ function mediaToDataUrl(data, mimeType) {
|
|
|
591
563
|
* @returns File content as string or ArrayBuffer, or null if not found
|
|
592
564
|
*/
|
|
593
565
|
function extractFile(content, path) {
|
|
594
|
-
const file = content.originalZip.
|
|
566
|
+
const file = content.originalZip.entry(path);
|
|
595
567
|
if (!file || !isParsedDocxEntry(path)) return Promise.resolve(null);
|
|
596
568
|
const lowerPath = path.toLowerCase();
|
|
597
|
-
if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) return file.
|
|
598
|
-
return file.
|
|
569
|
+
if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) return file.bytes().then(decodeXmlBytes);
|
|
570
|
+
return file.arrayBuffer();
|
|
599
571
|
}
|
|
600
572
|
/**
|
|
601
573
|
* Check if a file exists in the DOCX
|
|
@@ -605,7 +577,7 @@ function extractFile(content, path) {
|
|
|
605
577
|
* @returns true if file exists
|
|
606
578
|
*/
|
|
607
579
|
function hasFile(content, path) {
|
|
608
|
-
return content.originalZip.
|
|
580
|
+
return content.originalZip.has(path);
|
|
609
581
|
}
|
|
610
582
|
/**
|
|
611
583
|
* Get summary of DOCX content
|
|
@@ -627,7 +599,7 @@ function getContentSummary(content) {
|
|
|
627
599
|
footerCount: content.footers.size,
|
|
628
600
|
mediaCount: content.media.size,
|
|
629
601
|
fontCount: content.fonts.size,
|
|
630
|
-
totalFiles:
|
|
602
|
+
totalFiles: content.originalZip.paths().length
|
|
631
603
|
};
|
|
632
604
|
}
|
|
633
|
-
export { DocxSecurityError, extractFile, getContentSummary,
|
|
605
|
+
export { DocxSecurityError, extractFile, getContentSummary, getFileList, getMediaMimeType, hasFile, mediaToDataUrl, replaceRawDocxXmlParts, unzipDocx };
|
|
@@ -2,10 +2,10 @@ import { TRANSITIONAL_NAMESPACE_BY_STRICT_URI } from "./strictValueEncodings.gen
|
|
|
2
2
|
import { NUMBERS_PER_PERCENT, isStrictNamespaceUri, percentageSpelling, toTransitionalNamespaceUri, transitionalSlotEncoding } from "./transitionalSpelling.js";
|
|
3
3
|
import { roundHalfAwayFromZero, universalMeasureAs } from "./universalMeasure.js";
|
|
4
4
|
import { NAMESPACES, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, elementToXml, getAttributeByNamespaceUri, getChildElements, getLocalName, getNamespacePrefix, parseXml, resolveAttributeNamespaceUri } from "./xmlParser.js";
|
|
5
|
-
import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
|
|
6
5
|
import { getDocxXmlSafetyIssue } from "./xmlSafety.js";
|
|
7
6
|
import { TaggedError, panic } from "better-result";
|
|
8
7
|
import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
|
|
8
|
+
import { assertXmlResourceLimits } from "@stll/docx-core/xml/limits";
|
|
9
9
|
// src/docx/verbatimCapture.ts
|
|
10
10
|
/**
|
|
11
11
|
* The one way to turn parsed markup into a string a rebuilt part replays.
|
package/dist/docx/xmlParser.d.ts
CHANGED
|
@@ -1,32 +1,5 @@
|
|
|
1
1
|
import { ParseContext } from "./parseContext.js";
|
|
2
|
-
|
|
3
|
-
* XML element tree node — drop-in replacement for the `Element` type
|
|
4
|
-
* previously imported from `xml-js`. Every consumer imports this from
|
|
5
|
-
* `xmlParser.ts`, so the shape must stay identical.
|
|
6
|
-
*/
|
|
7
|
-
export type XmlElement = {
|
|
8
|
-
declaration?: {
|
|
9
|
-
attributes?: Record<string, string | number>;
|
|
10
|
-
};
|
|
11
|
-
instruction?: string;
|
|
12
|
-
attributes?: Record<string, string | number | undefined>;
|
|
13
|
-
cdata?: string;
|
|
14
|
-
doctype?: string;
|
|
15
|
-
comment?: string;
|
|
16
|
-
text?: string | number | boolean;
|
|
17
|
-
type?: string;
|
|
18
|
-
name?: string;
|
|
19
|
-
/** Resolved namespace URI; non-enumerable so legacy structural comparisons stay stable. */
|
|
20
|
-
namespaceUri?: string;
|
|
21
|
-
/** In-scope namespace declarations; non-enumerable so legacy comparisons stay stable. */
|
|
22
|
-
namespaceScope?: XmlNamespaceScope;
|
|
23
|
-
elements?: XmlElement[];
|
|
24
|
-
};
|
|
25
|
-
/** Parent-linked XML namespace declarations; each scope stores only local overrides. */
|
|
26
|
-
export type XmlNamespaceScope = {
|
|
27
|
-
bindings: ReadonlyMap<string, string>;
|
|
28
|
-
parent?: XmlNamespaceScope;
|
|
29
|
-
};
|
|
2
|
+
import { XmlElement, XmlNamespaceScope } from "@stll/docx-core/xml";
|
|
30
3
|
/**
|
|
31
4
|
* Common OOXML namespace URIs — re-exported from @stll/docx-utils.
|
|
32
5
|
*/
|
|
@@ -67,16 +40,11 @@ export declare const NAMESPACES: {
|
|
|
67
40
|
*/
|
|
68
41
|
export declare const OOXML_NAMESPACE_SCOPE: XmlNamespaceScope;
|
|
69
42
|
export declare function parseXml(xml: string, inheritedNamespaceScope?: XmlNamespaceScope): XmlElement;
|
|
70
|
-
/**
|
|
71
|
-
* The general-purpose reader {@link parseXml} falls back to, and the reference
|
|
72
|
-
* the streaming reader's equivalence tests compare against.
|
|
73
|
-
*/
|
|
74
|
-
export declare function parseXmlWithFastXmlParser(xml: string, inheritedNamespaceScope?: XmlNamespaceScope): XmlElement;
|
|
75
43
|
/**
|
|
76
44
|
* Serialize an XmlElement back to an XML string.
|
|
77
45
|
*
|
|
78
|
-
* Written here rather than handed to
|
|
79
|
-
* reasons. The builder writes a tab or a newline inside an attribute value
|
|
46
|
+
* Written here rather than handed to a general-purpose XML builder (the one
|
|
47
|
+
* folio used before) for two reasons. The builder writes a tab or a newline inside an attribute value
|
|
80
48
|
* literally, and XML 1.0 §3.3.3 has every conformant reader normalise those to
|
|
81
49
|
* a space: a `descr="two lines
"` a source file wrote came back as
|
|
82
50
|
* `descr="two lines "`. `escapeXmlAttribute` writes the character references
|
|
@@ -405,4 +373,5 @@ export declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDec
|
|
|
405
373
|
* original's scope leaves resolution unchanged whether or not the clone
|
|
406
374
|
* carries `xmlns` attributes of its own.
|
|
407
375
|
*/
|
|
408
|
-
export declare function cloneElement(element: XmlElement, overrides: Partial<XmlElement>): XmlElement;
|
|
376
|
+
export declare function cloneElement(element: XmlElement, overrides: Partial<XmlElement>): XmlElement;
|
|
377
|
+
export type { XmlElement, XmlNamespaceScope };
|
package/dist/docx/xmlParser.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { parseStreamingXml } from "./streamingXmlParser.js";
|
|
2
1
|
import { NUMBERS_PER_PERCENT, percentageSpelling, transitionalSlotEncoding } from "./transitionalSpelling.js";
|
|
3
2
|
import { universalMeasureAs } from "./universalMeasure.js";
|
|
4
|
-
import { EMPTY_NAMESPACE_SCOPE, attachXmlNamespaceContext, resolveNamespaceUri } from "./xmlNamespaceContext.js";
|
|
5
3
|
import { escapeXmlAttribute, escapeXmlText } from "@stll/docx-core";
|
|
6
4
|
import { PARSE_WARNING_CODES } from "@stll/docx-core/model";
|
|
7
|
-
import {
|
|
5
|
+
import { XmlReadError, readXml } from "@stll/docx-core/xml";
|
|
6
|
+
import { EMPTY_NAMESPACE_SCOPE, attachXmlNamespaceContext, resolveNamespaceUri } from "@stll/docx-core/xml/namespaces";
|
|
8
7
|
import { OOXML_NS } from "@stll/docx-utils";
|
|
9
8
|
// src/docx/xmlParser.ts
|
|
10
9
|
/**
|
|
@@ -27,69 +26,6 @@ import { OOXML_NS } from "@stll/docx-utils";
|
|
|
27
26
|
* - o: Office (extensions)
|
|
28
27
|
* - pic: Pictures
|
|
29
28
|
*/
|
|
30
|
-
const fxpParserOptions = {
|
|
31
|
-
preserveOrder: true,
|
|
32
|
-
ignoreAttributes: false,
|
|
33
|
-
attributeNamePrefix: "",
|
|
34
|
-
trimValues: false,
|
|
35
|
-
textNodeName: "#text",
|
|
36
|
-
parseTagValue: false,
|
|
37
|
-
parseAttributeValue: false,
|
|
38
|
-
ignoreDeclaration: true,
|
|
39
|
-
ignorePiTags: true,
|
|
40
|
-
jPath: false,
|
|
41
|
-
processEntities: true,
|
|
42
|
-
htmlEntities: true
|
|
43
|
-
};
|
|
44
|
-
const fxpParserOptionsWithStopNodes = {
|
|
45
|
-
...fxpParserOptions,
|
|
46
|
-
stopNodes: ["*.w:binData"]
|
|
47
|
-
};
|
|
48
|
-
const fxpParser = new XMLParser(fxpParserOptions);
|
|
49
|
-
const fxpParserWithStopNodes = new XMLParser(fxpParserOptionsWithStopNodes);
|
|
50
|
-
/** Text node key used by fast-xml-parser in preserveOrder mode. */
|
|
51
|
-
const TEXT_KEY = "#text";
|
|
52
|
-
/** Attribute group key used by fast-xml-parser in preserveOrder mode. */
|
|
53
|
-
const ATTR_KEY = ":@";
|
|
54
|
-
/**
|
|
55
|
-
* Convert a fast-xml-parser preserveOrder node into an XmlElement.
|
|
56
|
-
*
|
|
57
|
-
* In preserveOrder mode every node is an object with exactly one "real" key
|
|
58
|
-
* (the tag name or `#text`) whose value is the children array, plus an
|
|
59
|
-
* optional `:@` key holding the attributes object.
|
|
60
|
-
*/
|
|
61
|
-
function fxpNodeToElement(node, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
|
|
62
|
-
if (TEXT_KEY in node) return {
|
|
63
|
-
type: "text",
|
|
64
|
-
text: node[TEXT_KEY]
|
|
65
|
-
};
|
|
66
|
-
const attrs = node[ATTR_KEY];
|
|
67
|
-
for (const key in node) {
|
|
68
|
-
if (key === ATTR_KEY) continue;
|
|
69
|
-
const children = node[key];
|
|
70
|
-
const element = {
|
|
71
|
-
type: "element",
|
|
72
|
-
name: key
|
|
73
|
-
};
|
|
74
|
-
if (attrs) element.attributes = attrs;
|
|
75
|
-
const namespaceScope = attachXmlNamespaceContext(element, inheritedNamespaceScope);
|
|
76
|
-
if (children.length > 0) {
|
|
77
|
-
for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index], namespaceScope);
|
|
78
|
-
element.elements = children;
|
|
79
|
-
}
|
|
80
|
-
return element;
|
|
81
|
-
}
|
|
82
|
-
return { type: "element" };
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Convert the top-level fast-xml-parser preserveOrder array into an
|
|
86
|
-
* XmlElement that matches xml-js's non-compact root structure:
|
|
87
|
-
* `{ elements: [...] }`.
|
|
88
|
-
*/
|
|
89
|
-
function fxpToRootElement(nodes, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
|
|
90
|
-
for (let index = 0; index < nodes.length; index += 1) nodes[index] = fxpNodeToElement(nodes[index], inheritedNamespaceScope);
|
|
91
|
-
return { elements: nodes };
|
|
92
|
-
}
|
|
93
29
|
/**
|
|
94
30
|
* Common OOXML namespace URIs — re-exported from @stll/docx-utils.
|
|
95
31
|
*/
|
|
@@ -112,21 +48,21 @@ const NAMESPACES = OOXML_NS;
|
|
|
112
48
|
*/
|
|
113
49
|
const OOXML_NAMESPACE_SCOPE = { bindings: new Map(Object.entries(OOXML_NS)) };
|
|
114
50
|
function parseXml(xml, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
51
|
+
const read = readXml(xml, {
|
|
52
|
+
conformance: "tolerant",
|
|
53
|
+
inheritedNamespaceScope
|
|
54
|
+
});
|
|
55
|
+
if (read.status === "rejected") throw new XmlReadError({
|
|
56
|
+
message: `XML part could not be read: ${read.reason}`,
|
|
57
|
+
reason: read.reason
|
|
58
|
+
});
|
|
59
|
+
return read.value;
|
|
124
60
|
}
|
|
125
61
|
/**
|
|
126
62
|
* Serialize an XmlElement back to an XML string.
|
|
127
63
|
*
|
|
128
|
-
* Written here rather than handed to
|
|
129
|
-
* reasons. The builder writes a tab or a newline inside an attribute value
|
|
64
|
+
* Written here rather than handed to a general-purpose XML builder (the one
|
|
65
|
+
* folio used before) for two reasons. The builder writes a tab or a newline inside an attribute value
|
|
130
66
|
* literally, and XML 1.0 §3.3.3 has every conformant reader normalise those to
|
|
131
67
|
* a space: a `descr="two lines
"` a source file wrote came back as
|
|
132
68
|
* `descr="two lines "`. `escapeXmlAttribute` writes the character references
|
|
@@ -853,4 +789,4 @@ function cloneElement(element, overrides) {
|
|
|
853
789
|
attachXmlNamespaceContext(clone, element.namespaceScope);
|
|
854
790
|
return clone;
|
|
855
791
|
}
|
|
856
|
-
export { NAMESPACES, OFFICE_RELATIONSHIP_NAMESPACE_URIS, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findChildrenByNamespaceUri, findDeep, findWordprocessingChild, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffAttribute, parseOnOffChild, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument,
|
|
792
|
+
export { NAMESPACES, OFFICE_RELATIONSHIP_NAMESPACE_URIS, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findChildrenByNamespaceUri, findDeep, findWordprocessingChild, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffAttribute, parseOnOffChild, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument, resolveAttributeNamespaceUri, selectAlternateContentBranch };
|
package/dist/docx/xmlSafety.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { wellFormedXmlIssue } from "@stll/docx-core/xml";
|
|
2
2
|
// src/docx/xmlSafety.ts
|
|
3
3
|
const DOCTYPE_PATTERN = /<!DOCTYPE(?:\s|>)/iu;
|
|
4
4
|
const BUILT_IN_ENTITIES = /* @__PURE__ */ new Set([
|
|
@@ -75,9 +75,9 @@ const scanName = (xml, start) => {
|
|
|
75
75
|
} : null;
|
|
76
76
|
};
|
|
77
77
|
/**
|
|
78
|
-
* Reject lexical forms
|
|
79
|
-
* This is deliberately a small XML lexer, not a second
|
|
80
|
-
* nesting remains
|
|
78
|
+
* Reject lexical forms with a DOCX-specific verdict before the reader's
|
|
79
|
+
* well-formedness check. This is deliberately a small XML lexer, not a second
|
|
80
|
+
* tree parser: structural nesting remains the reader's job.
|
|
81
81
|
*/
|
|
82
82
|
const hasInvalidXmlLexicalForm = (xml) => {
|
|
83
83
|
if (!containsOnlyXmlCharacters(xml)) return true;
|
|
@@ -160,6 +160,6 @@ const getDocxXmlSafetyIssue = (xml) => {
|
|
|
160
160
|
if (DOCTYPE_PATTERN.test(xml)) return "doctype-forbidden";
|
|
161
161
|
if (hasForbiddenEntity(xml)) return "entity-forbidden";
|
|
162
162
|
if (hasInvalidXmlLexicalForm(xml)) return "not-well-formed";
|
|
163
|
-
return
|
|
163
|
+
return wellFormedXmlIssue(xml) === null ? null : "not-well-formed";
|
|
164
164
|
};
|
|
165
165
|
export { getDocxXmlSafetyIssue };
|
|
@@ -1124,7 +1124,8 @@ function renderPage(page, context, options = {}) {
|
|
|
1124
1124
|
}
|
|
1125
1125
|
if (shouldClipHeader) {
|
|
1126
1126
|
headerEl.style.maxHeight = `${availableHeaderHeight}px`;
|
|
1127
|
-
headerEl.style.
|
|
1127
|
+
headerEl.style.overflowX = "visible";
|
|
1128
|
+
headerEl.style.overflowY = "clip";
|
|
1128
1129
|
headerEl.style.overflowClipMargin = `${HEADER_FOOTER_RULE_CLIP_MARGIN}px`;
|
|
1129
1130
|
}
|
|
1130
1131
|
pageEl.append(headerEl);
|
|
@@ -1177,7 +1178,8 @@ function renderPage(page, context, options = {}) {
|
|
|
1177
1178
|
}
|
|
1178
1179
|
if (shouldClipFooter) {
|
|
1179
1180
|
footerEl.style.maxHeight = `${availableFooterHeight}px`;
|
|
1180
|
-
footerEl.style.
|
|
1181
|
+
footerEl.style.overflowX = "visible";
|
|
1182
|
+
footerEl.style.overflowY = "clip";
|
|
1181
1183
|
footerEl.style.overflowClipMargin = `${HEADER_FOOTER_RULE_CLIP_MARGIN}px`;
|
|
1182
1184
|
}
|
|
1183
1185
|
pageEl.append(footerEl);
|