@stll/folio-core 0.64.1 → 0.65.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.
@@ -1,13 +1,12 @@
1
1
  import { bytesToDataUrl } from "../utils/base64.js";
2
- import { encodeUtf8 } from "../utils/textEncoding.js";
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
7
  import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "./xmlResourceLimits.js";
9
- import JSZip from "jszip";
10
- import { writeZipPart } from "@stll/docx-core/zip";
8
+ import { Result } from "better-result";
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 maxRecords = limits.maxFiles * 2;
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 = Object.entries(zip.files).filter(([, file]) => !file.dir);
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, path) => {
159
- if (!verifyUnreadEntries || isStoredZipEntry(file)) return;
153
+ const verifyEntry = (file) => {
154
+ if (!verifyUnreadEntries) return;
160
155
  extractionTasks.push(async () => {
161
- await verifyEntryWithinLimits({
162
- file,
163
- path,
164
- limits,
165
- budget: inflationBudget
166
- });
156
+ await verifyEntryData(file);
167
157
  return null;
168
158
  });
169
159
  };
170
- const inflate = async (file, maxEntryBytes) => await inflateEntryWithinLimits({
171
- entry: file,
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, path);
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, path);
170
+ verifyEntry(file);
197
171
  continue;
198
172
  }
199
- extractionTasks.push(async () => {
200
- const result = await inflate(file, limits.maxXmlBytes);
201
- if (!result.ok) throw inflationLimitError(result.limit, path);
202
- return {
203
- type: "xml",
204
- path,
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, path);
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, path);
187
+ verifyEntry(file);
218
188
  continue;
219
189
  }
220
190
  extractionTasks.push(async () => {
221
- const result = await inflate(file, limits.maxMediaBytes);
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, path);
203
+ verifyEntry(file);
240
204
  continue;
241
205
  }
242
- extractionTasks.push(async () => {
243
- const result = await inflate(file, limits.maxFontBytes);
244
- if (!result.ok) {
245
- if (result.limit !== "entry") throw inflationLimitError(result.limit, path);
246
- return null;
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, inflationBudget)) {
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
- writeZipPart({
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.generateAsync({
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
- function inflationLimitError(limit, path) {
353
- switch (limit) {
354
- case "declared-size": return new DocxSecurityError(`DOCX entry inflates past its declared size: ${path}`);
355
- case "compression-ratio": return new DocxSecurityError(`DOCX entry exceeds the maximum compression ratio: ${path}`);
356
- case "entry": return new DocxSecurityError(`DOCX entry exceeds maximum size: ${path}`);
357
- case "total": return new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
358
- case "aborted": return new DocxSecurityError(`DOCX entry was not read after another entry failed: ${path}`);
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 marks the shared budget aborted, which stops
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, budget) {
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 && !budget.aborted) {
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
- try {
377
- results[index] = await task();
378
- } catch (error) {
379
- budget.aborted = true;
380
- throw error;
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
- * Inflate an entry without keeping it, to bound what a later read of it can
389
- * produce. A limit refuses the package. A corrupt entry does not: it was never
390
- * going to be read here, a later read stops at the same byte, and refusing
391
- * the whole document over a part folio does not model would be a regression.
392
- */
393
- async function verifyEntryWithinLimits({ file, path, limits, budget }) {
394
- let result;
395
- try {
396
- result = await inflateEntryWithinLimits({
397
- entry: file,
398
- maxEntryBytes: Number.POSITIVE_INFINITY,
399
- maxCompressionRatio: compressionRatioLimitFor(path, limits.maxCompressionRatio),
400
- budget,
401
- retain: false
402
- });
403
- } catch {
404
- return;
405
- }
406
- if (!result.ok) throw inflationLimitError(result.limit, path);
407
- }
408
- async function loadDocxZip(buffer, maxFiles) {
409
- try {
410
- return {
411
- zip: await JSZip.loadAsync(buffer),
412
- buffer
413
- };
414
- } catch (error) {
415
- const repairedBuffer = repairTruncatedEndOfCentralDirectory(buffer, maxFiles);
416
- if (!repairedBuffer) throw error;
417
- try {
418
- return {
419
- zip: await JSZip.loadAsync(repairedBuffer),
420
- buffer: repairedBuffer
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 !== null && declaredSize > maxBytes;
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 Object.keys(content.originalZip.files)) if (!content.originalZip.files[path]?.dir && isParsedDocxEntry(path)) files.push(path);
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.file(path);
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.async("uint8array").then(decodeXmlBytes);
598
- return file.async("arraybuffer");
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.file(path) !== null;
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: Object.keys(content.originalZip.files).filter((p) => !content.originalZip.files[p]?.dir).length
602
+ totalFiles: content.originalZip.paths().length
631
603
  };
632
604
  }
633
- export { DocxSecurityError, extractFile, getContentSummary, getEntryUncompressedSize, getFileList, getMediaMimeType, hasFile, mediaToDataUrl, replaceRawDocxXmlParts, unzipDocx };
605
+ export { DocxSecurityError, extractFile, getContentSummary, getFileList, getMediaMimeType, hasFile, mediaToDataUrl, replaceRawDocxXmlParts, unzipDocx };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.64.1",
3
+ "version": "0.65.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -171,16 +171,15 @@
171
171
  "perf": "bun scripts/profile-editor.ts"
172
172
  },
173
173
  "dependencies": {
174
- "@stll/docx-core": "^0.39.2",
174
+ "@stll/docx-core": "^0.40.0",
175
175
  "@stll/docx-utils": "^0.2.0",
176
176
  "@stll/template-conditions": "^0.6.2",
177
- "@stll/utf8-boundaries": "0.1.2",
177
+ "@stll/utf8-boundaries": "0.2.0",
178
178
  "better-result": "3.0.1",
179
179
  "csstype": "^3.1.3",
180
180
  "dompurify": "^3.4.17",
181
181
  "fast-xml-parser": "^5.11.2",
182
182
  "hyphen": "1.14.1",
183
- "jszip": "3.10.2",
184
183
  "utif2": "^4.1.0",
185
184
  "valibot": "1.5.0"
186
185
  },