@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,4 +1,4 @@
1
- import { decodeText, encodeUtf8 } from "../utils/textEncoding.js";
1
+ import { decodeText } from "../utils/textEncoding.js";
2
2
  import { parseCommentsExtended } from "./commentParser.js";
3
3
  import { withoutExhaustedComments, withoutOrphanCommentRanges } from "./commentRangeIntegrity.js";
4
4
  import { hasUnsynthesizedReplyRanges } from "./commentReplyMarkers.js";
@@ -18,7 +18,7 @@ import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
18
18
  import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
19
19
  import { buildStructuralDocumentPatch } from "./structuralXmlPatch.js";
20
20
  import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
21
- import { writeZipPart } from "@stll/docx-core/zip";
21
+ import { ZipArchive } from "@stll/docx-core/zip";
22
22
  // src/docx/selectiveSave.ts
23
23
  /**
24
24
  * Splice edited footnote/endnote paragraphs into their parts and return the
@@ -39,22 +39,22 @@ async function patchNoteParts(zip, doc, candidateIds, updates) {
39
39
  const remainingIds = new Set(candidateIds);
40
40
  if (footnotes.length === 0 && endnotes.length === 0) return remainingIds;
41
41
  const findPart = (conventionalLowerPath) => {
42
- const direct = zip.file(conventionalLowerPath);
42
+ const direct = zip.entry(conventionalLowerPath);
43
43
  if (direct) return direct;
44
- for (const [path, file] of Object.entries(zip.files)) if (!file.dir && path.toLowerCase() === conventionalLowerPath) return file;
44
+ for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === conventionalLowerPath) return file;
45
45
  return null;
46
46
  };
47
47
  const patchPart = async (conventionalLowerPath, serialize) => {
48
48
  const file = findPart(conventionalLowerPath);
49
49
  if (!file) return true;
50
- const originalXml = await file.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
50
+ const originalXml = await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
51
51
  const originalIds = collectParaIds(originalXml);
52
52
  const partIds = /* @__PURE__ */ new Set();
53
53
  for (const id of remainingIds) if (originalIds.has(id)) partIds.add(id);
54
54
  if (partIds.size === 0) return true;
55
55
  const patched = buildPatchedNoteXml(originalXml, serialize(), partIds);
56
56
  if (patched === null) return false;
57
- updates.set(file.name, patched);
57
+ updates.set(file.path, patched);
58
58
  for (const id of partIds) remainingIds.delete(id);
59
59
  return true;
60
60
  };
@@ -63,9 +63,9 @@ async function patchNoteParts(zip, doc, candidateIds, updates) {
63
63
  return remainingIds;
64
64
  }
65
65
  const findZipEntryCaseInsensitive = (zip, lowerPath) => {
66
- const direct = zip.file(lowerPath);
66
+ const direct = zip.entry(lowerPath);
67
67
  if (direct) return direct;
68
- for (const [path, file] of Object.entries(zip.files)) if (!file.dir && path.toLowerCase() === lowerPath) return file;
68
+ for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === lowerPath) return file;
69
69
  return null;
70
70
  };
71
71
  const commentsExtendedInfoEqual = (a, b) => {
@@ -94,9 +94,9 @@ async function patchCommentsExtended(zip, plan, updates) {
94
94
  const desiredXml = serializeCommentsExtended(plan);
95
95
  const existing = findZipEntryCaseInsensitive(zip, COMMENTS_EXTENDED_PART_LOWER);
96
96
  if (!desiredXml) return existing === null;
97
- const baselineInfo = existing ? parseCommentsExtended(await existing.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }))) : /* @__PURE__ */ new Map();
97
+ const baselineInfo = existing ? parseCommentsExtended(await existing.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }))) : /* @__PURE__ */ new Map();
98
98
  if (commentsExtendedInfoEqual(parseCommentsExtended(desiredXml), baselineInfo)) return true;
99
- updates.set(existing?.name ?? "word/commentsExtended.xml", desiredXml);
99
+ updates.set(existing?.path ?? "word/commentsExtended.xml", desiredXml);
100
100
  return ensureCommentsExtendedPackaging(zip, updates);
101
101
  }
102
102
  /**
@@ -107,12 +107,12 @@ async function patchCommentsExtended(zip, plan, updates) {
107
107
  */
108
108
  async function ensureCommentsExtendedPackaging(zip, updates) {
109
109
  const ctPath = "[Content_Types].xml";
110
- const ctXml = updates.get(ctPath) ?? await zip.file(ctPath)?.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
110
+ const ctXml = updates.get(ctPath) ?? await zip.entry(ctPath)?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
111
111
  if (ctXml === void 0) return false;
112
112
  const nextCt = addCommentsExtendedOverride(ctXml);
113
113
  if (nextCt !== ctXml) updates.set(ctPath, nextCt);
114
114
  const relsPath = "word/_rels/document.xml.rels";
115
- const relsXml = updates.get(relsPath) ?? await zip.file(relsPath)?.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
115
+ const relsXml = updates.get(relsPath) ?? await zip.entry(relsPath)?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
116
116
  if (relsXml === void 0) return false;
117
117
  const nextRels = addCommentsExtendedRelationship(relsXml);
118
118
  if (nextRels !== relsXml) updates.set(relsPath, nextRels);
@@ -139,15 +139,15 @@ async function patchNumberingPart(zip, doc, updates) {
139
139
  const numbering = doc.package.numbering;
140
140
  if (!numbering || numbering.abstractNums.length === 0 && numbering.nums.length === 0) return true;
141
141
  const conventionalLowerPath = "word/numbering.xml";
142
- let file = zip.file(conventionalLowerPath);
142
+ let file = zip.entry(conventionalLowerPath);
143
143
  if (!file) {
144
- for (const [path, entry] of Object.entries(zip.files)) if (!entry.dir && path.toLowerCase() === conventionalLowerPath) {
144
+ for (const [path, entry] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === conventionalLowerPath) {
145
145
  file = entry;
146
146
  break;
147
147
  }
148
148
  }
149
149
  if (!file) return false;
150
- const originalXml = await file.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
150
+ const originalXml = await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
151
151
  const baselineXml = serializeNumberingXml(parseNumbering(originalXml).definitions);
152
152
  const currentXml = serializeNumberingXml(numbering);
153
153
  if (currentXml === baselineXml) return true;
@@ -157,7 +157,7 @@ async function patchNumberingPart(zip, doc, updates) {
157
157
  currentXml
158
158
  });
159
159
  if (patched === null) return false;
160
- if (patched !== originalXml) updates.set(file.name, patched);
160
+ if (patched !== originalXml) updates.set(file.path, patched);
161
161
  return true;
162
162
  }
163
163
  /**
@@ -175,10 +175,10 @@ async function patchNumberingPart(zip, doc, updates) {
175
175
  * same package for the same source.
176
176
  */
177
177
  const queueSettingsUpdates = async (zip, updates) => {
178
- const settingsFile = zip.file("word/settings.xml");
178
+ const settingsFile = zip.entry("word/settings.xml");
179
179
  if (!settingsFile) return;
180
- const relsFile = zip.file("word/_rels/settings.xml.rels");
181
- const filtered = withoutAttachedTemplate(await settingsFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })), await relsFile?.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })));
180
+ const relsFile = zip.entry("word/_rels/settings.xml.rels");
181
+ const filtered = withoutAttachedTemplate(await settingsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })), await relsFile?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })));
182
182
  if (filtered.settingsXml !== void 0) updates.set("word/settings.xml", filtered.settingsXml);
183
183
  if (filtered.relsXml !== void 0) updates.set("word/_rels/settings.xml.rels", filtered.relsXml);
184
184
  };
@@ -206,21 +206,17 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
206
206
  const ownsCommentsPart = hasOwnedCommentsPart(doc);
207
207
  const commentPlan = planCommentParts(comments);
208
208
  try {
209
- const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
209
+ const zip = await ZipArchive.read(originalBuffer);
210
210
  if (await hasUnmaterializedHyperlinkBindings(doc, zip)) return null;
211
- for (const [path, file] of Object.entries(zip.files)) if (!file.dir && isUnsafePackagePath(path)) return null;
211
+ for (const path of zip.paths()) if (isUnsafePackagePath(path)) return null;
212
212
  if (originalBuffer !== doc.originalBuffer) {
213
213
  const sourceParts = /* @__PURE__ */ new Map();
214
- for (const [path, file] of Object.entries(zip.files)) {
214
+ for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) {
215
215
  const lowerPath = path.toLowerCase();
216
- if (file.dir || !lowerPath.startsWith("word/") || !lowerPath.endsWith(".xml")) continue;
217
- sourceParts.set(path, await file.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })));
216
+ if (!lowerPath.startsWith("word/") || !lowerPath.endsWith(".xml")) continue;
217
+ sourceParts.set(path, await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })));
218
218
  }
219
- for (const [path, xml] of normalizeImportedNumericIds(sourceParts)) if (xml !== sourceParts.get(path)) writeZipPart({
220
- zip,
221
- path,
222
- data: encodeUtf8(xml)
223
- });
219
+ for (const [path, xml] of normalizeImportedNumericIds(sourceParts)) if (xml !== sourceParts.get(path)) zip.set(path, xml);
224
220
  }
225
221
  if (options.bodyAuthority === "canonical") await publishCanonicalImageResources({
226
222
  document: doc,
@@ -229,9 +225,9 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
229
225
  });
230
226
  const updates = /* @__PURE__ */ new Map();
231
227
  if (changedParaIds.size > 0 || structuralChange) {
232
- const docXmlFile = zip.file("word/document.xml");
228
+ const docXmlFile = zip.entry("word/document.xml");
233
229
  if (!docXmlFile) return null;
234
- const originalDocXml = await docXmlFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
230
+ const originalDocXml = await docXmlFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
235
231
  const serializedDocXml = serializeDocument(doc, readRootNamespaceBindings(originalDocXml), {
236
232
  xml: originalDocXml,
237
233
  bodyAuthority: options.bodyAuthority,
@@ -241,7 +237,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
241
237
  const originalBodyParaIds = structuralChange ? collectParaIds(originalDocXml) : void 0;
242
238
  if (structuralChange && doc.package.numbering) {
243
239
  const numberingFile = findZipEntryCaseInsensitive(zip, "word/numbering.xml");
244
- const baselineNumbering = numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }))).definitions) : "";
240
+ const baselineNumbering = numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }))).definitions) : "";
245
241
  const added = collectAddedNumberingDefs(baselineNumbering, serializeNumberingXml(doc.package.numbering));
246
242
  if (added.abstractNums.size > 0 || added.nums.size > 0) return null;
247
243
  }
@@ -264,23 +260,23 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
264
260
  }
265
261
  }
266
262
  const sourceCommentsFile = findZipEntryCaseInsensitive(zip, "word/comments.xml");
267
- const commentsPartPath = sourceCommentsFile?.name ?? "word/comments.xml";
268
- const sourceCommentsXml = sourceCommentsFile ? await sourceCommentsFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })) : void 0;
263
+ const commentsPartPath = sourceCommentsFile?.path ?? "word/comments.xml";
264
+ const sourceCommentsXml = sourceCommentsFile ? await sourceCommentsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })) : void 0;
269
265
  const commentsUnchanged = sourceCommentsXml !== void 0 && canReplayCommentsPart(doc, sourceCommentsXml);
270
266
  if (!commentsUnchanged && (hasComments || ownsCommentsPart || sourceCommentsFile)) {
271
267
  const commentsXml = serializeComments(commentPlan, sourceCommentsXml === void 0 ? void 0 : readRootNamespaceBindings(sourceCommentsXml));
272
268
  if (commentsXml !== sourceCommentsXml) updates.set(commentsPartPath, commentsXml);
273
269
  }
274
270
  if (hasComments || ownsCommentsPart) {
275
- const ctFile = zip.file("[Content_Types].xml");
271
+ const ctFile = zip.entry("[Content_Types].xml");
276
272
  if (ctFile) {
277
- const ctXml = await ctFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
273
+ const ctXml = await ctFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
278
274
  if (!ctXml.toLowerCase().includes("/word/comments.xml")) updates.set("[Content_Types].xml", ctXml.replace("</Types>", `<Override PartName="/${commentsPartPath}" ContentType="${COMMENTS_CONTENT_TYPE}"/></Types>`));
279
275
  }
280
276
  const relsPath = "word/_rels/document.xml.rels";
281
- const relsFile = zip.file(relsPath);
277
+ const relsFile = zip.entry(relsPath);
282
278
  if (relsFile) {
283
- const relsXml = await relsFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
279
+ const relsXml = await relsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
284
280
  if (!relsXml.toLowerCase().includes("comments.xml")) {
285
281
  const maxId = findMaxRId(relsXml);
286
282
  updates.set(relsPath, relsXml.replace("</Relationships>", `<Relationship Id="rId${maxId + 1}" Type="${RELATIONSHIP_TYPES.comments}" Target="${commentsPartPath.slice(5)}"/></Relationships>`));
@@ -308,16 +304,16 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
308
304
  onDiagnostic: options.onDiagnostic
309
305
  })) updates.set(path, xml);
310
306
  await queueSettingsUpdates(zip, updates);
311
- const corePropsFile = zip.file("docProps/core.xml");
307
+ const corePropsFile = zip.entry("docProps/core.xml");
312
308
  if (corePropsFile) {
313
- const corePropsXml = await corePropsFile.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
309
+ const corePropsXml = await corePropsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
314
310
  updates.set("docProps/core.xml", updateCoreProperties(corePropsXml, { updateModifiedDate: true }));
315
311
  }
316
312
  if ([...updates.keys()].some((path) => {
317
313
  const lowerPath = path.toLowerCase();
318
314
  return lowerPath.startsWith("word/") && (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels"));
319
315
  })) {
320
- const sourceDocumentXml = await findZipEntryCaseInsensitive(zip, "word/document.xml")?.async("text");
316
+ const sourceDocumentXml = await findZipEntryCaseInsensitive(zip, "word/document.xml")?.text();
321
317
  if (detectDocxConformanceClass(sourceDocumentXml ?? null) === DOCX_CONFORMANCE_CLASSES.STRICT) return null;
322
318
  }
323
319
  return await applyUpdatesToZip(zip, updates);
@@ -11,7 +11,7 @@
11
11
  * - `selectiveSaveTripwire` — run selective + full and compare bytes for CI
12
12
  * observability. Never blocks the user-visible save.
13
13
  * - `selectiveSaveMaxBytes` — refuse the selective path for original buffers
14
- * above this size, since holding them in memory plus the JSZip overhead is
14
+ * above this size, since holding them in memory plus the archive overhead is
15
15
  * the dominant cost.
16
16
  */
17
17
  export type FolioSelectiveSaveFlags = {
@@ -1,4 +1,5 @@
1
1
  import { panic } from "better-result";
2
+ import { ZipArchive } from "@stll/docx-core/zip";
2
3
  // src/docx/selectiveSaveTripwire.ts
3
4
  /**
4
5
  * Selective Save Tripwire
@@ -16,17 +17,10 @@ import { panic } from "better-result";
16
17
  */
17
18
  const IGNORED_PATHS = /* @__PURE__ */ new Set(["docProps/core.xml"]);
18
19
  async function listEntries(buffer) {
19
- const zip = await (await import("jszip")).default.loadAsync(buffer);
20
- const paths = [];
21
- for (const [path, entry] of Object.entries(zip.files)) {
22
- if (entry.dir) continue;
23
- if (IGNORED_PATHS.has(path)) continue;
24
- paths.push(path);
25
- }
26
- paths.sort();
20
+ const zip = await ZipArchive.read(buffer);
27
21
  return {
28
22
  zip,
29
- paths
23
+ paths: zip.paths().filter((path) => !IGNORED_PATHS.has(path)).toSorted()
30
24
  };
31
25
  }
32
26
  function bytesEqual(a, b) {
@@ -35,9 +29,9 @@ function bytesEqual(a, b) {
35
29
  return true;
36
30
  }
37
31
  function readEntryBytes(zip, path, side) {
38
- const entry = zip.file(path);
32
+ const entry = zip.entry(path);
39
33
  if (!entry) panic(`Missing ${side} zip entry: ${path}`);
40
- return entry.async("uint8array");
34
+ return entry.bytes();
41
35
  }
42
36
  function readCommonEntryBytes(selectiveZip, fullZip) {
43
37
  return Promise.all(selectiveZip.paths.map(async (path) => {
@@ -1,11 +1,9 @@
1
- import { encodeUtf8 } from "../../utils/textEncoding.js";
2
1
  import { loadDocxArchive } from "./boundedArchive.js";
3
2
  import { FOLIO_DOCX_XML_PATCH_PROPOSAL_PROFILE, InvalidFolioDocxXmlPatchProposalError, evaluateDocxXmlPatchProposal, parseFolioDocxXmlPatchProposal } from "./evaluateDocxXmlPatchProposal.js";
4
3
  import { FOLIO_DOCX_CONFORMANCE_PROFILE, validateDocxConformance } from "./validateDocxConformance.js";
5
4
  import { TaggedError, panic } from "better-result";
6
5
  import { createHash } from "node:crypto";
7
- import JSZip from "jszip";
8
- import { writeZipPart } from "@stll/docx-core/zip";
6
+ import { ZipArchive } from "@stll/docx-core/zip";
9
7
  // src/docx/server/applyDocxXmlPatchProposal.ts
10
8
  const FOLIO_DOCX_XML_PATCH_APPLICATION_VERSION = 1;
11
9
  const FOLIO_DOCX_XML_PATCH_APPLICATION_PROFILE = "folio-xml-patch-application-v1";
@@ -60,29 +58,26 @@ const verifyArchiveContents = async (bytes, options) => {
60
58
  cause
61
59
  });
62
60
  }
63
- for (const entry of archive.entryMetadata.toSorted(({ path: left }, { path: right }) => left < right ? -1 : Number(left > right))) {
64
- if (entry.directory) continue;
65
- try {
66
- if (await archive.readEntryUint8(entry.path) === null) throw new FolioDocxXmlPatchApplicationError({
67
- message: "An archive entry disappeared during verification.",
68
- stage: "verify",
69
- part: entry.path
70
- });
71
- } catch (cause) {
72
- if (cause instanceof FolioDocxXmlPatchApplicationError) throw cause;
73
- throw new FolioDocxXmlPatchApplicationError({
74
- message: "An archive entry could not be verified within the configured limits.",
75
- stage: "verify",
76
- part: entry.path,
77
- cause
78
- });
79
- }
61
+ for (const entry of archive.entryMetadata.toSorted(({ path: left }, { path: right }) => left < right ? -1 : Number(left > right))) try {
62
+ if (await archive.readEntryUint8(entry.path) === null) throw new FolioDocxXmlPatchApplicationError({
63
+ message: "An archive entry disappeared during verification.",
64
+ stage: "verify",
65
+ part: entry.path
66
+ });
67
+ } catch (cause) {
68
+ if (cause instanceof FolioDocxXmlPatchApplicationError) throw cause;
69
+ throw new FolioDocxXmlPatchApplicationError({
70
+ message: "An archive entry could not be verified within the configured limits.",
71
+ stage: "verify",
72
+ part: entry.path,
73
+ cause
74
+ });
80
75
  }
81
76
  };
82
77
  const applyReplacements = async ({ bytes, proposal }) => {
83
78
  let zip;
84
79
  try {
85
- zip = await JSZip.loadAsync(bytes);
80
+ zip = await ZipArchive.read(bytes);
86
81
  } catch (cause) {
87
82
  throw new FolioDocxXmlPatchApplicationError({
88
83
  message: "The evaluated package could not be loaded for replacement.",
@@ -91,31 +86,15 @@ const applyReplacements = async ({ bytes, proposal }) => {
91
86
  });
92
87
  }
93
88
  for (const replacement of proposal.replacements) {
94
- const current = zip.file(replacement.path);
95
- if (current === null) throw new FolioDocxXmlPatchApplicationError({
89
+ const current = zip.entry(replacement.path);
90
+ if (current === void 0) throw new FolioDocxXmlPatchApplicationError({
96
91
  message: "An evaluated package part was unavailable during replacement.",
97
92
  stage: "replace"
98
93
  });
99
- writeZipPart({
100
- zip,
101
- path: replacement.path,
102
- data: encodeUtf8(replacement.replacementXml),
103
- options: {
104
- date: current.date,
105
- comment: current.comment,
106
- unixPermissions: current.unixPermissions,
107
- dosPermissions: current.dosPermissions,
108
- compression: "DEFLATE",
109
- compressionOptions: { level: 6 }
110
- }
111
- });
94
+ zip.set(replacement.path, replacement.replacementXml, { modified: current.modified });
112
95
  }
113
96
  try {
114
- return await zip.generateAsync({
115
- type: "uint8array",
116
- compression: "DEFLATE",
117
- compressionOptions: { level: 6 }
118
- });
97
+ return await zip.generate({ level: 6 });
119
98
  } catch (cause) {
120
99
  throw new FolioDocxXmlPatchApplicationError({
121
100
  message: "The replacement package could not be generated.",
@@ -37,8 +37,7 @@ export type DocxArchiveOptions = {
37
37
  };
38
38
  export type DocxArchiveEntry = {
39
39
  readonly path: string;
40
- readonly directory: boolean;
41
- readonly declaredUncompressedBytes: number | null;
40
+ readonly declaredUncompressedBytes: number;
42
41
  };
43
42
  export type DocxArchiveReadOptions = {
44
43
  maxBytes?: number;
@@ -1,8 +1,8 @@
1
1
  import { decodeText } from "../../utils/textEncoding.js";
2
- import { compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits } from "../archiveInflation.js";
2
+ import { DOCX_COMPRESSION_RATIO_GRACE_BYTES, isRatioBoundedPart } from "../archiveInflation.js";
3
3
  import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "../xmlResourceLimits.js";
4
- import { TaggedError } from "better-result";
5
- import JSZip from "jszip";
4
+ import { Result, TaggedError } from "better-result";
5
+ import { ZipArchive, ZipError } from "@stll/docx-core/zip";
6
6
  // src/docx/server/boundedArchive.ts
7
7
  const DOCX_MAX_ENTRY_BYTES = 134217728;
8
8
  const DOCX_MAX_TOTAL_BYTES = 268435456;
@@ -11,27 +11,42 @@ const DOCX_MAX_INPUT_BYTES = 52428800;
11
11
  const DocxArchiveErrorBase = TaggedError("DocxArchiveError");
12
12
  /** Error raised when a DOCX archive cannot be loaded within configured limits. */
13
13
  var DocxArchiveError = class extends DocxArchiveErrorBase {};
14
- const readLimitError = ({ limit, path, maxEntryBytes, maxTotalBytes, maxCompressionRatio }) => {
15
- switch (limit) {
16
- case "declared-size": return new DocxArchiveError({
17
- message: `DOCX entry "${path}" inflated past its declared size`,
18
- reason: "entry-too-large"
14
+ /** The archive error for a read the ZIP reader refused. */
15
+ const archiveErrorFor = (error, { maxEntryBytes, maxTotalBytes, maxEntries, maxCompressionRatio }) => {
16
+ const entry = error.path === void 0 ? "" : ` "${error.path}"`;
17
+ switch (error.reason) {
18
+ case "too-many-entries": return new DocxArchiveError({
19
+ message: `DOCX archive holds more than ${maxEntries} entries`,
20
+ reason: "too-many-entries"
19
21
  });
20
- case "compression-ratio": return new DocxArchiveError({
21
- message: `DOCX entry "${path}" exceeded the ${maxCompressionRatio}:1 compression ratio limit`,
22
- reason: "compression-ratio-exceeded"
22
+ case "entry-too-large": return new DocxArchiveError({
23
+ message: `DOCX entry${entry} declares more than ${maxEntryBytes} bytes`,
24
+ reason: "entry-too-large"
23
25
  });
24
- case "entry": return new DocxArchiveError({
25
- message: `DOCX entry "${path}" exceeded the ${maxEntryBytes}-byte limit`,
26
+ case "declared-size-exceeded": return new DocxArchiveError({
27
+ message: `DOCX entry${entry} inflated past its declared size`,
26
28
  reason: "entry-too-large"
27
29
  });
28
- case "total":
29
- case "aborted": return new DocxArchiveError({
30
- message: `DOCX archive exceeded the ${maxTotalBytes}-byte cumulative limit while reading "${path}"`,
30
+ case "total-too-large": return new DocxArchiveError({
31
+ message: `DOCX archive declares more than ${maxTotalBytes} cumulative bytes`,
31
32
  reason: "total-too-large"
32
33
  });
34
+ case "compression-ratio": return new DocxArchiveError({
35
+ message: error.path === void 0 ? `DOCX archive declares more than ${maxCompressionRatio} bytes per archive byte` : `DOCX entry${entry} declares more than ${maxCompressionRatio} bytes per compressed byte`,
36
+ reason: "compression-ratio-exceeded"
37
+ });
38
+ case "malformed":
39
+ case "unsupported":
40
+ case "invalid-entry-name":
41
+ case "duplicate-entry":
42
+ case "corrupt-entry": return new DocxArchiveError({
43
+ message: "Failed to parse DOCX archive",
44
+ reason: "load-failed",
45
+ cause: error
46
+ });
33
47
  }
34
48
  };
49
+ const toArchiveError = (cause, limits) => cause instanceof ZipError ? archiveErrorFor(cause, limits) : cause;
35
50
  const resolveByteLimit = ({ value, fallback, name }) => {
36
51
  const limit = value ?? fallback;
37
52
  if (!Number.isSafeInteger(limit) || limit < 0) throw new DocxArchiveError({
@@ -40,7 +55,6 @@ const resolveByteLimit = ({ value, fallback, name }) => {
40
55
  });
41
56
  return limit;
42
57
  };
43
- const asBytes = (bytes) => bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
44
58
  const loadDocxArchive = async (bytes, options = {}) => {
45
59
  const maxInputBytes = resolveByteLimit({
46
60
  value: options.maxInputBytes,
@@ -71,103 +85,68 @@ const loadDocxArchive = async (bytes, options = {}) => {
71
85
  message: `DOCX input contains ${bytes.byteLength} bytes (max ${maxInputBytes})`,
72
86
  reason: "input-too-large"
73
87
  });
74
- if (countCentralDirectoryRecords(asBytes(bytes), maxEntries) > maxEntries) throw new DocxArchiveError({
75
- message: `DOCX archive holds more than ${maxEntries} entries`,
76
- reason: "too-many-entries"
77
- });
78
- let zip;
79
- try {
80
- zip = await JSZip.loadAsync(bytes);
81
- } catch (cause) {
82
- throw new DocxArchiveError({
83
- message: "Failed to parse DOCX archive",
84
- reason: "load-failed",
85
- cause
86
- });
87
- }
88
- const archiveEntries = Object.values(zip.files);
89
- if (archiveEntries.length > maxEntries) throw new DocxArchiveError({
90
- message: `DOCX archive declares ${archiveEntries.length} entries (max ${maxEntries})`,
91
- reason: "too-many-entries"
92
- });
93
- let declaredTotalBytes = 0;
94
- let declaredTotalKnown = true;
95
- for (const entry of archiveEntries) {
96
- if (entry.dir) continue;
97
- const { compressedBytes, uncompressedBytes } = getZipEntrySizes(entry);
98
- if (uncompressedBytes === null) {
99
- declaredTotalKnown = false;
100
- continue;
101
- }
102
- if (uncompressedBytes > maxEntryBytes) throw new DocxArchiveError({
103
- message: `DOCX entry "${entry.name}" declares ${uncompressedBytes} bytes (max ${maxEntryBytes})`,
104
- reason: "entry-too-large"
105
- });
106
- if (exceedsCompressionRatio({
107
- inflatedBytes: uncompressedBytes,
108
- compressedBytes,
109
- maxRatio: compressionRatioLimitFor(entry.name, maxCompressionRatio)
110
- })) throw new DocxArchiveError({
111
- message: `DOCX entry "${entry.name}" declares more than ${maxCompressionRatio} bytes per compressed byte`,
112
- reason: "compression-ratio-exceeded"
113
- });
114
- declaredTotalBytes += uncompressedBytes;
115
- }
116
- if (declaredTotalKnown && declaredTotalBytes > maxTotalBytes) throw new DocxArchiveError({
117
- message: `DOCX archive declares ${declaredTotalBytes} cumulative bytes (max ${maxTotalBytes})`,
118
- reason: "total-too-large"
119
- });
120
- if (exceedsCompressionRatio({
121
- inflatedBytes: declaredTotalBytes,
122
- compressedBytes: bytes.byteLength,
123
- maxRatio: maxCompressionRatio
124
- })) throw new DocxArchiveError({
125
- message: `DOCX archive declares more than ${maxCompressionRatio} bytes per archive byte`,
126
- reason: "compression-ratio-exceeded"
88
+ const limits = {
89
+ maxEntryBytes,
90
+ maxTotalBytes,
91
+ maxEntries,
92
+ maxCompressionRatio
93
+ };
94
+ const read = await Result.tryPromise({
95
+ try: async () => await ZipArchive.read(bytes, {
96
+ limits: {
97
+ maxEntries,
98
+ maxEntryBytes,
99
+ maxTotalBytes,
100
+ maxCompressionRatio,
101
+ compressionRatioGraceBytes: DOCX_COMPRESSION_RATIO_GRACE_BYTES
102
+ },
103
+ isCompressionRatioExempt: (path) => !isRatioBoundedPart(path)
104
+ }),
105
+ catch: (cause) => toArchiveError(cause, limits)
127
106
  });
107
+ if (Result.isError(read)) throw read.error;
108
+ const zip = read.value;
109
+ const archiveEntries = zip.entries();
128
110
  const xmlLimits = {
129
111
  ...FOLIO_XML_RESOURCE_LIMITS,
130
112
  ...options.xmlLimits
131
113
  };
132
114
  const xmlBudget = createXmlPackageBudget();
133
115
  const countedParts = /* @__PURE__ */ new Set();
134
- const inflationBudget = createInflationBudget(maxTotalBytes);
135
- let readChain = Promise.resolve();
116
+ let readBytes = 0;
136
117
  const readEntry = async (path, readOptions = {}) => {
137
118
  const requestedMaxBytes = resolveByteLimit({
138
119
  value: readOptions.maxBytes,
139
120
  fallback: maxEntryBytes,
140
121
  name: "DOCX entry read byte limit"
141
122
  });
142
- const work = async () => {
143
- const entry = zip.file(path);
144
- if (!entry) return null;
145
- const entryLimit = Math.min(requestedMaxBytes, maxEntryBytes);
146
- const result = await inflateEntryWithinLimits({
147
- entry,
148
- maxEntryBytes: entryLimit,
149
- maxCompressionRatio: compressionRatioLimitFor(path, maxCompressionRatio),
150
- budget: inflationBudget
151
- });
152
- if (!result.ok) throw readLimitError({
153
- limit: result.limit,
154
- path,
155
- maxEntryBytes: entryLimit,
156
- maxTotalBytes,
157
- maxCompressionRatio
158
- });
159
- return result.bytes;
160
- };
161
- const next = readChain.then(work, work);
162
- readChain = next.then(() => void 0, () => void 0);
163
- return await next;
123
+ const entry = zip.entry(path);
124
+ if (!entry) return null;
125
+ const entryLimit = Math.min(requestedMaxBytes, maxEntryBytes);
126
+ if (entry.size > entryLimit) throw new DocxArchiveError({
127
+ message: `DOCX entry "${path}" exceeded the ${entryLimit}-byte limit`,
128
+ reason: "entry-too-large"
129
+ });
130
+ if (readBytes + entry.size > maxTotalBytes) throw new DocxArchiveError({
131
+ message: `DOCX archive exceeded the ${maxTotalBytes}-byte cumulative limit while reading "${path}"`,
132
+ reason: "total-too-large"
133
+ });
134
+ readBytes += entry.size;
135
+ const content = await Result.tryPromise({
136
+ try: async () => await entry.bytes(),
137
+ catch: (cause) => toArchiveError(cause, limits)
138
+ });
139
+ if (Result.isError(content)) {
140
+ readBytes -= entry.size;
141
+ throw content.error;
142
+ }
143
+ return content.value;
164
144
  };
165
145
  return {
166
- entries: Object.freeze(archiveEntries.map(({ name }) => name)),
146
+ entries: Object.freeze(archiveEntries.map(({ path }) => path)),
167
147
  entryMetadata: Object.freeze(archiveEntries.map((entry) => ({
168
- path: entry.name,
169
- directory: entry.dir,
170
- declaredUncompressedBytes: getZipEntrySizes(entry).uncompressedBytes
148
+ path: entry.path,
149
+ declaredUncompressedBytes: entry.size
171
150
  }))),
172
151
  async readEntryString(path) {
173
152
  const content = await readEntry(path);
@@ -30,7 +30,7 @@ export type FolioDocxPackagePart = {
30
30
  readonly path: string;
31
31
  readonly kind: FolioDocxPackagePartKind;
32
32
  readonly contentType: string | null;
33
- readonly declaredUncompressedBytes: number | null;
33
+ readonly declaredUncompressedBytes: number;
34
34
  };
35
35
  export type FolioDocxInspectedXmlPart = {
36
36
  readonly path: string;
@@ -200,7 +200,7 @@ const inspectDocxPackage = async (bytes, options = {}) => {
200
200
  const archive = await loadDocxArchive(bytes, options.archive);
201
201
  const contentTypesBytes = await archive.readEntryUint8(CONTENT_TYPES_PATH, { maxBytes: CONTENT_TYPES_READ_LIMIT });
202
202
  const declarations = parseContentTypes(contentTypesBytes === null ? null : decodeXml(contentTypesBytes, CONTENT_TYPES_PATH));
203
- const parts = archive.entryMetadata.filter(({ directory }) => !directory).map((entry) => toPackagePart(entry, declarations)).toSorted(({ path: left }, { path: right }) => comparePackagePaths(left, right));
203
+ const parts = archive.entryMetadata.map((entry) => toPackagePart(entry, declarations)).toSorted(({ path: left }, { path: right }) => comparePackagePaths(left, right));
204
204
  const partsByPath = new Map(parts.map((part) => [part.path, part]));
205
205
  validateRequestedParts({
206
206
  requestedPaths,
@@ -1,4 +1,4 @@
1
- import JSZip from "jszip";
1
+ import { ZipArchive } from "@stll/docx-core/zip";
2
2
  export declare class DocxSecurityError extends Error {
3
3
  constructor(message: string);
4
4
  }
@@ -75,7 +75,7 @@ export type RawDocxContent = {
75
75
  media: Map<string, ArrayBuffer>;
76
76
  fonts: Map<string, ArrayBuffer>;
77
77
  allXml: Map<string, string>;
78
- originalZip: JSZip;
78
+ originalZip: ZipArchive;
79
79
  originalBuffer: ArrayBuffer;
80
80
  warnings: string[];
81
81
  /** True when the input was a password-protected CFB container. */
@@ -100,7 +100,6 @@ export type UnzipDocxBehavior = {
100
100
  export declare function unzipDocx(buffer: ArrayBuffer, options?: DocxUnzipOptions, { verifyUnreadEntries }?: UnzipDocxBehavior): Promise<RawDocxContent>;
101
101
  /** Keep extracted projections and the source ZIP aligned after parser normalization. */
102
102
  export declare const replaceRawDocxXmlParts: (content: RawDocxContent, parts: ReadonlyMap<string, string>) => Promise<void>;
103
- export declare function getEntryUncompressedSize(file: JSZip.JSZipObject): number | null;
104
103
  /**
105
104
  * Get a list of all files in the DOCX
106
105
  *