@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.
@@ -56,7 +56,7 @@ import { TaggedError, panic } from "better-result";
56
56
  import { REVIEW_CARRIERS, finalizeParagraphFormatting, numberingLevelEntries, setDocumentBodyComments } from "@stll/docx-core/model";
57
57
  import { reconcileDocumentPartRelationships } from "@stll/docx-core/ops";
58
58
  import { EditorState } from "prosemirror-state";
59
- import JSZip from "jszip";
59
+ import { ZipArchive } from "@stll/docx-core/zip";
60
60
  // src/ai-edits/headless.ts
61
61
  /**
62
62
  * Headless `.docx` review path: buffer -> apply AI edits -> buffer, with
@@ -1990,8 +1990,8 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1990
1990
  if (xml === null) return null;
1991
1991
  const extension = media.path.split(".").at(-1)?.toLowerCase();
1992
1992
  if (!extension || !/^[a-z0-9]+$/u.test(extension)) return null;
1993
- const originalZip = await JSZip.loadAsync(this.originalBuffer);
1994
- const existingPaths = new Set(Object.keys(originalZip.files).map((path) => path.toLowerCase()));
1993
+ const originalZip = await ZipArchive.read(this.originalBuffer);
1994
+ const existingPaths = new Set(originalZip.paths().map((path) => path.toLowerCase()));
1995
1995
  let suffix = 1;
1996
1996
  let path = `word/media/folio-import-${suffix}.${extension}`;
1997
1997
  while (existingPaths.has(path.toLowerCase()) || this.baseDocument.package.media?.has(path) || this.importedMedia.has(path)) path = `word/media/folio-import-${++suffix}.${extension}`;
@@ -1 +1,6 @@
1
+ /**
2
+ * Date comparison output from the comparison's timestamp. Imported entries
3
+ * retain source dates, and saves can stamp `dcterms:modified` from the wall
4
+ * clock; restamping both makes repeated comparisons reproducible.
5
+ */
1
6
  export declare const withFixedPackageDates: (buffer: ArrayBuffer, date: Date) => Promise<ArrayBuffer>;
@@ -1,13 +1,12 @@
1
- import { decodeText, encodeUtf8 } from "../utils/textEncoding.js";
2
- import JSZip from "jszip";
3
- import { writeZipPart } from "@stll/docx-core/zip";
1
+ import { decodeText } from "../utils/textEncoding.js";
2
+ import { ZipArchive } from "@stll/docx-core/zip";
4
3
  // src/compare/reproducible-package.ts
5
4
  /**
6
5
  * Date comparison output from the comparison's timestamp. Imported entries
7
6
  * retain source dates, and saves can stamp `dcterms:modified` from the wall
8
7
  * clock; restamping both makes repeated comparisons reproducible.
9
8
  */
10
- /** JSZip deflate level `repackDocx` writes DOCX parts at. */
9
+ /** ZipArchive deflate level `repackDocx` writes DOCX parts at. */
11
10
  const DOCX_COMPRESSION_LEVEL = 6;
12
11
  const CORE_PROPERTIES_PATH = "docProps/core.xml";
13
12
  const MODIFIED_ELEMENT = /<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u;
@@ -18,24 +17,12 @@ const MODIFIED_ELEMENT = /<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u;
18
17
  */
19
18
  const withFixedModifiedDate = (corePropsXml, date) => corePropsXml.replace(MODIFIED_ELEMENT, `<dcterms:modified xsi:type="dcterms:W3CDTF">${date.toISOString()}</dcterms:modified>`);
20
19
  const withFixedPackageDates = async (buffer, date) => {
21
- const zip = await JSZip.loadAsync(buffer);
22
- const coreProps = zip.file(CORE_PROPERTIES_PATH);
23
- if (coreProps) writeZipPart({
24
- zip,
25
- path: CORE_PROPERTIES_PATH,
26
- data: encodeUtf8(withFixedModifiedDate(await coreProps.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })), date)),
27
- options: {
28
- compression: "DEFLATE",
29
- compressionOptions: { level: DOCX_COMPRESSION_LEVEL }
30
- }
31
- });
32
- zip.forEach((_path, file) => {
33
- file.date = date;
34
- });
35
- return await zip.generateAsync({
36
- type: "arraybuffer",
37
- compression: "DEFLATE",
38
- compressionOptions: { level: DOCX_COMPRESSION_LEVEL }
39
- });
20
+ const zip = await ZipArchive.read(buffer);
21
+ const coreProps = zip.entry(CORE_PROPERTIES_PATH);
22
+ if (coreProps) zip.set(CORE_PROPERTIES_PATH, withFixedModifiedDate(decodeText(await coreProps.bytes(), { ignoreBOM: true }), date));
23
+ return (await zip.generate({
24
+ level: DOCX_COMPRESSION_LEVEL,
25
+ modified: date
26
+ })).buffer;
40
27
  };
41
28
  export { withFixedPackageDates };
@@ -1,15 +1,10 @@
1
- import JSZip from "jszip";
2
- declare module "jszip" {
3
- interface JSZipObject {
4
- /**
5
- * Chunked read of the entry content, missing from the published typings.
6
- * `nodeStream` is this stream wrapped in a Node.js `Readable`, which
7
- * browsers and web workers cannot provide; the stream itself is
8
- * platform-neutral.
9
- */
10
- internalStream(type: "uint8array"): JSZip.JSZipStreamHelper<Uint8Array>;
11
- }
12
- }
1
+ /**
2
+ * Compression bounds for DOCX packages.
3
+ *
4
+ * The archive reader (`@stll/docx-core/zip`) checks every size an archive
5
+ * declares before anything inflates, and stops each inflation at its entry's
6
+ * declared size. The values here are the DOCX policy it is given.
7
+ */
13
8
  /**
14
9
  * Default ceiling on inflated bytes per compressed byte.
15
10
  *
@@ -26,100 +21,10 @@ export declare const DOCX_MAX_COMPRESSION_RATIO = 200;
26
21
  * matter. The byte caps still apply below this size.
27
22
  */
28
23
  export declare const DOCX_COMPRESSION_RATIO_GRACE_BYTES: number;
29
- /**
30
- * Upper bound on the central-directory records in `bytes`, counted without
31
- * parsing the archive and stopping once the count passes `stopAfter`.
32
- *
33
- * Every record JSZip reads starts with the record signature, so the number of
34
- * signature occurrences cannot be lower than the number of entries JSZip would
35
- * build, whatever the end-of-central-directory record claims.
36
- */
37
- export declare const countCentralDirectoryRecords: (bytes: Uint8Array, stopAfter: number) => number;
38
- /** Sizes a loaded entry declares in the central directory. */
39
- export type ZipEntrySizes = {
40
- readonly compressedBytes: number | null;
41
- readonly uncompressedBytes: number | null;
42
- };
43
- /** Declared sizes of an entry JSZip loaded from an archive. */
44
- export declare const getZipEntrySizes: (entry: JSZip.JSZipObject) => ZipEntrySizes;
45
- /**
46
- * Whether a loaded entry is stored rather than compressed. A stored entry
47
- * reads back exactly the bytes the archive carries, so it cannot expand.
48
- */
49
- export declare const isStoredZipEntry: (entry: JSZip.JSZipObject) => boolean;
50
24
  /**
51
25
  * Whether the per-entry ratio cap applies to a part. Markup and text parts
52
26
  * never compress anywhere near the cap. Binary parts can: an uncompressed
53
27
  * bitmap of one colour compresses like a run of one byte. Those stay bounded
54
28
  * by the byte caps and the package-wide ratio instead.
55
29
  */
56
- export declare const isRatioBoundedPart: (path: string) => boolean;
57
- /** The per-entry ratio cap for `path`: `maxRatio`, or none for a binary part. */
58
- export declare const compressionRatioLimitFor: (path: string, maxRatio: number) => number;
59
- type CompressionRatioCheck = {
60
- inflatedBytes: number;
61
- compressedBytes: number | null;
62
- maxRatio: number;
63
- };
64
- /**
65
- * Whether `inflatedBytes` produced from `compressedBytes` passes the ratio cap.
66
- * Sizes under {@link DOCX_COMPRESSION_RATIO_GRACE_BYTES} never do, and an
67
- * unknown compressed size is left to the byte caps.
68
- */
69
- export declare const exceedsCompressionRatio: ({ inflatedBytes, compressedBytes, maxRatio }: CompressionRatioCheck) => boolean;
70
- /**
71
- * Inflated bytes charged across one package. `aborted` stops every inflation
72
- * sharing the budget at its next chunk, once one of them has failed the
73
- * package.
74
- */
75
- export type InflationBudget = {
76
- readonly maxTotalBytes: number;
77
- inflatedBytes: number;
78
- aborted: boolean;
79
- };
80
- export declare const createInflationBudget: (maxTotalBytes: number) => InflationBudget;
81
- /**
82
- * The bound an inflation stopped at.
83
- *
84
- * - `declared-size` — the entry produced more than its declared size.
85
- * - `compression-ratio` — the entry passed the ratio cap for its compressed size.
86
- * - `entry` — the entry passed the per-entry cap the caller set.
87
- * - `total` — the package passed its cumulative budget.
88
- * - `aborted` — another inflation sharing the budget already failed.
89
- */
90
- export type InflationLimit = "declared-size" | "compression-ratio" | "entry" | "total" | "aborted";
91
- export type InflateEntryResult = {
92
- readonly ok: true;
93
- readonly bytes: Uint8Array<ArrayBuffer>;
94
- } | {
95
- readonly ok: false;
96
- readonly limit: InflationLimit;
97
- };
98
- export type InflateEntryOptions = {
99
- entry: JSZip.JSZipObject;
100
- /** Most bytes this entry may inflate to. */
101
- maxEntryBytes: number;
102
- /** Ratio cap while streaming; see {@link compressionRatioLimitFor}. */
103
- maxCompressionRatio: number;
104
- budget: InflationBudget;
105
- /**
106
- * `false` inflates the entry only to prove it stays within its bounds; the
107
- * chunks are dropped and the result carries no bytes. A package part that
108
- * is never read here is still inflated by a later save, so the bound has
109
- * to be established for it too.
110
- */
111
- retain?: boolean;
112
- };
113
- /**
114
- * Inflate one entry chunk by chunk, checking every bound before a chunk is
115
- * kept. Pausing the stream abandons the rest of the entry, so a limit bounds
116
- * memory rather than reporting an overrun afterwards.
117
- *
118
- * The budget is charged as chunks arrive, which keeps concurrent inflations
119
- * of one package honest, and refunded when this entry stops at a limit or
120
- * fails, since none of its bytes are kept. Stream errors (a corrupt entry)
121
- * reject; limits resolve with `ok: false` for the caller to map onto its own
122
- * error or skip policy.
123
- */
124
- export declare const inflateEntryWithinLimits: ({ entry, maxEntryBytes, maxCompressionRatio, budget, retain }: InflateEntryOptions) => Promise<InflateEntryResult>;
125
- export {};
30
+ export declare const isRatioBoundedPart: (path: string) => boolean;
@@ -1,5 +1,12 @@
1
1
  // src/docx/archiveInflation.ts
2
2
  /**
3
+ * Compression bounds for DOCX packages.
4
+ *
5
+ * The archive reader (`@stll/docx-core/zip`) checks every size an archive
6
+ * declares before anything inflates, and stops each inflation at its entry's
7
+ * declared size. The values here are the DOCX policy it is given.
8
+ */
9
+ /**
3
10
  * Default ceiling on inflated bytes per compressed byte.
4
11
  *
5
12
  * Document parts in the repository's fixtures stay under 75:1, and parts over a
@@ -15,57 +22,6 @@ const DOCX_MAX_COMPRESSION_RATIO = 200;
15
22
  * matter. The byte caps still apply below this size.
16
23
  */
17
24
  const DOCX_COMPRESSION_RATIO_GRACE_BYTES = 4194304;
18
- const CENTRAL_DIRECTORY_RECORD_SIGNATURE = [
19
- 80,
20
- 75,
21
- 1,
22
- 2
23
- ];
24
- /**
25
- * Upper bound on the central-directory records in `bytes`, counted without
26
- * parsing the archive and stopping once the count passes `stopAfter`.
27
- *
28
- * Every record JSZip reads starts with the record signature, so the number of
29
- * signature occurrences cannot be lower than the number of entries JSZip would
30
- * build, whatever the end-of-central-directory record claims.
31
- */
32
- const countCentralDirectoryRecords = (bytes, stopAfter) => {
33
- const [first, second, third, fourth] = CENTRAL_DIRECTORY_RECORD_SIGNATURE;
34
- let count = 0;
35
- let offset = bytes.indexOf(first);
36
- while (offset !== -1 && offset + 3 < bytes.length) {
37
- if (bytes[offset + 1] === second && bytes[offset + 2] === third && bytes[offset + 3] === fourth) {
38
- count += 1;
39
- if (count > stopAfter) return count;
40
- }
41
- offset = bytes.indexOf(first, offset + 1);
42
- }
43
- return count;
44
- };
45
- const finiteSize = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
46
- /** Declared sizes of an entry JSZip loaded from an archive. */
47
- const getZipEntrySizes = (entry) => {
48
- const data = "_data" in entry ? entry._data : void 0;
49
- if (typeof data !== "object" || data === null) return {
50
- compressedBytes: null,
51
- uncompressedBytes: null
52
- };
53
- return {
54
- compressedBytes: "compressedSize" in data ? finiteSize(data.compressedSize) : null,
55
- uncompressedBytes: "uncompressedSize" in data ? finiteSize(data.uncompressedSize) : null
56
- };
57
- };
58
- const STORED_COMPRESSION_MAGIC = "\0\0";
59
- /**
60
- * Whether a loaded entry is stored rather than compressed. A stored entry
61
- * reads back exactly the bytes the archive carries, so it cannot expand.
62
- */
63
- const isStoredZipEntry = (entry) => {
64
- const data = "_data" in entry ? entry._data : void 0;
65
- if (typeof data !== "object" || data === null || !("compression" in data)) return false;
66
- const { compression } = data;
67
- return typeof compression === "object" && compression !== null && "magic" in compression && compression.magic === STORED_COMPRESSION_MAGIC;
68
- };
69
25
  const RATIO_BOUNDED_EXTENSIONS = /* @__PURE__ */ new Set([
70
26
  "xml",
71
27
  "rels",
@@ -84,97 +40,4 @@ const RATIO_BOUNDED_EXTENSIONS = /* @__PURE__ */ new Set([
84
40
  * by the byte caps and the package-wide ratio instead.
85
41
  */
86
42
  const isRatioBoundedPart = (path) => RATIO_BOUNDED_EXTENSIONS.has(path.slice(path.lastIndexOf(".") + 1).toLowerCase());
87
- /** The per-entry ratio cap for `path`: `maxRatio`, or none for a binary part. */
88
- const compressionRatioLimitFor = (path, maxRatio) => isRatioBoundedPart(path) ? maxRatio : Number.POSITIVE_INFINITY;
89
- /**
90
- * Whether `inflatedBytes` produced from `compressedBytes` passes the ratio cap.
91
- * Sizes under {@link DOCX_COMPRESSION_RATIO_GRACE_BYTES} never do, and an
92
- * unknown compressed size is left to the byte caps.
93
- */
94
- const exceedsCompressionRatio = ({ inflatedBytes, compressedBytes, maxRatio }) => compressedBytes !== null && inflatedBytes > 4194304 && inflatedBytes > compressedBytes * maxRatio;
95
- const createInflationBudget = (maxTotalBytes) => ({
96
- maxTotalBytes,
97
- inflatedBytes: 0,
98
- aborted: false
99
- });
100
- const EMPTY_BYTES = /* @__PURE__ */ new Uint8Array(0);
101
- const concatChunks = (chunks, totalBytes) => {
102
- const merged = new Uint8Array(totalBytes);
103
- let offset = 0;
104
- for (const chunk of chunks) {
105
- merged.set(chunk, offset);
106
- offset += chunk.length;
107
- }
108
- return merged;
109
- };
110
- /**
111
- * Inflate one entry chunk by chunk, checking every bound before a chunk is
112
- * kept. Pausing the stream abandons the rest of the entry, so a limit bounds
113
- * memory rather than reporting an overrun afterwards.
114
- *
115
- * The budget is charged as chunks arrive, which keeps concurrent inflations
116
- * of one package honest, and refunded when this entry stops at a limit or
117
- * fails, since none of its bytes are kept. Stream errors (a corrupt entry)
118
- * reject; limits resolve with `ok: false` for the caller to map onto its own
119
- * error or skip policy.
120
- */
121
- const inflateEntryWithinLimits = async ({ entry, maxEntryBytes, maxCompressionRatio, budget, retain = true }) => {
122
- const { compressedBytes, uncompressedBytes } = getZipEntrySizes(entry);
123
- return await new Promise((resolve, reject) => {
124
- const stream = entry.internalStream("uint8array");
125
- const chunks = [];
126
- let entryBytes = 0;
127
- let settled = false;
128
- const settle = () => {
129
- if (settled) return false;
130
- settled = true;
131
- return true;
132
- };
133
- const refund = () => {
134
- budget.inflatedBytes -= entryBytes;
135
- };
136
- const stop = (limit) => {
137
- if (!settle()) return;
138
- stream.pause();
139
- refund();
140
- resolve({
141
- ok: false,
142
- limit
143
- });
144
- };
145
- const limitFor = () => {
146
- if (budget.aborted) return "aborted";
147
- if (uncompressedBytes !== null && entryBytes > uncompressedBytes) return "declared-size";
148
- if (exceedsCompressionRatio({
149
- inflatedBytes: entryBytes,
150
- compressedBytes,
151
- maxRatio: maxCompressionRatio
152
- })) return "compression-ratio";
153
- if (entryBytes > maxEntryBytes) return "entry";
154
- if (budget.inflatedBytes > budget.maxTotalBytes) return "total";
155
- return null;
156
- };
157
- stream.on("data", (chunk) => {
158
- if (settled) return;
159
- entryBytes += chunk.length;
160
- budget.inflatedBytes += chunk.length;
161
- const limit = limitFor();
162
- if (limit !== null) {
163
- stop(limit);
164
- return;
165
- }
166
- if (retain) chunks.push(chunk);
167
- }).on("end", () => {
168
- if (!settle()) return;
169
- resolve({
170
- ok: true,
171
- bytes: retain ? concatChunks(chunks, entryBytes) : EMPTY_BYTES
172
- });
173
- }).on("error", (error) => {
174
- if (!settle()) return;
175
- refund();
176
- reject(error);
177
- }).resume();
178
- });
179
- };
180
- export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isRatioBoundedPart, isStoredZipEntry };
43
+ export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, isRatioBoundedPart };
@@ -5,7 +5,7 @@ import { readDocumentSectionFacts } from "./documentSectionFacts.js";
5
5
  import { parseDocx } from "./parser.js";
6
6
  import { assertSectionCarriersMatchModel } from "./rezip.js";
7
7
  import { serializeDocument } from "./serializer/documentSerializer.js";
8
- import JSZip from "jszip";
8
+ import { ZipArchive } from "@stll/docx-core/zip";
9
9
  // src/docx/canonicalStoryRepack.ts
10
10
  /** Canonical snapshots own explicit section references; the PM projection does not. */
11
11
  const referenceKey = ({ element, type, rId }) => `${element}:${type}:${rId}`;
@@ -19,7 +19,7 @@ const xmlFingerprint = async (xml) => {
19
19
  const repackWithCanonicalStoryRemovals = async ({ document, repack }) => {
20
20
  const originalBuffer = document.originalBuffer;
21
21
  if (!originalBuffer) return repack();
22
- const xml = await (await JSZip.loadAsync(originalBuffer)).file("word/document.xml")?.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
22
+ const xml = await (await ZipArchive.read(originalBuffer)).entry("word/document.xml")?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
23
23
  if (!xml) return repack();
24
24
  const before = readDocumentSectionFacts(xml);
25
25
  const serializedXml = serializeDocument(document);
@@ -1,5 +1,4 @@
1
1
  import { deterministicHexId } from "../utils/hexId.js";
2
- import { encodeUtf8 } from "../utils/textEncoding.js";
3
2
  import { resolvePackageRelationshipTarget } from "./packageParts.js";
4
3
  import { spliceXml } from "./selectiveXmlPatch.js";
5
4
  import { loadDocxArchive } from "./server/boundedArchive.js";
@@ -7,8 +6,7 @@ import { scanStreamingXmlElements } from "./streamingXmlParser.js";
7
6
  import { resolveNamespaceUri } from "./xmlNamespaceContext.js";
8
7
  import { OFFICE_RELATIONSHIP_NAMESPACE_URIS, WORDPROCESSINGML_NAMESPACE_URIS, getAttribute, getChildElements, getLocalName, getNamespaceUri, parseXmlDocument, resolveAttributeNamespaceUri } from "./xmlParser.js";
9
8
  import { TaggedError } from "better-result";
10
- import JSZip from "jszip";
11
- import { writeZipPart } from "@stll/docx-core/zip";
9
+ import { ZipArchive } from "@stll/docx-core/zip";
12
10
  // src/docx/ensureParaIds.ts
13
11
  /**
14
12
  * Headless `w14:paraId` normalization for a `.docx` buffer.
@@ -321,7 +319,7 @@ const mainDocumentPart = (rels, entries) => {
321
319
  /** OPC part names are case-insensitive; compare lowercased. */
322
320
  const isTargetPart = (path) => TARGET_PART_PATTERNS.some((pattern) => pattern.test(path.toLowerCase()));
323
321
  const toUint8Array = (docx) => docx instanceof Uint8Array ? docx : new Uint8Array(docx);
324
- const hasDigitalSignatureParts = (zip) => Object.values(zip.files).some(({ dir, name }) => !dir && name.toLowerCase().startsWith(DIGITAL_SIGNATURE_PART_PREFIX));
322
+ const hasDigitalSignatureParts = (zip) => zip.entries().some(({ path }) => path.toLowerCase().startsWith(DIGITAL_SIGNATURE_PART_PREFIX));
325
323
  /**
326
324
  * Backfill `w14:paraId` on every paragraph of a `.docx` buffer. See the
327
325
  * module doc for the exact contract. Throws {@link EnsureParaIdsError} when
@@ -381,31 +379,18 @@ const ensureParaIdsInternal = async (docx, options) => {
381
379
  alreadyComplete: true,
382
380
  mintedParaIds: []
383
381
  };
384
- const zip = await JSZip.loadAsync(docx);
382
+ const zip = await ZipArchive.read(docx);
385
383
  if (hasDigitalSignatureParts(zip) && options.allowSignedPackageMutation !== true) throw new EnsureParaIdsError({
386
384
  reason: ENSURE_PARA_IDS_REASONS.SIGNED_PACKAGE,
387
385
  message: "Refusing to normalize a digitally signed package because rewriting OOXML invalidates its signatures. Warn the user and pass allowSignedPackageMutation only if invalidation is acceptable."
388
386
  });
389
387
  for (const [partPath, content] of updates) {
390
- const sourceEntry = zip.file(partPath);
391
- if (sourceEntry === null) throw createEnsureParaIdsError(`Package part disappeared during normalization: ${partPath}`);
392
- writeZipPart({
393
- zip,
394
- path: partPath,
395
- data: encodeUtf8(content),
396
- options: {
397
- compression: "DEFLATE",
398
- compressionOptions: { level: 6 },
399
- date: sourceEntry.date
400
- }
401
- });
388
+ const sourceEntry = zip.entry(partPath);
389
+ if (sourceEntry === void 0) throw createEnsureParaIdsError(`Package part disappeared during normalization: ${partPath}`);
390
+ zip.set(partPath, content, { modified: sourceEntry.modified });
402
391
  }
403
392
  return {
404
- docx: await zip.generateAsync({
405
- type: "uint8array",
406
- compression: "DEFLATE",
407
- compressionOptions: { level: 6 }
408
- }),
393
+ docx: await zip.generate({ level: 6 }),
409
394
  assigned,
410
395
  deduplicated,
411
396
  alreadyComplete: false,
@@ -1,9 +1,8 @@
1
- import { decodeText, encodeUtf8 } from "../utils/textEncoding.js";
2
- import { countCentralDirectoryRecords, createInflationBudget, inflateEntryWithinLimits } from "./archiveInflation.js";
1
+ import { decodeText } from "../utils/textEncoding.js";
2
+ import "./archiveInflation.js";
3
3
  import { elementToXml, getLocalName, parseXmlDocument } from "./xmlParser.js";
4
- import { TaggedError } from "better-result";
5
- import JSZip from "jszip";
6
- import { writeZipPart } from "@stll/docx-core/zip";
4
+ import { Result, TaggedError } from "better-result";
5
+ import { ZipArchive, ZipError } from "@stll/docx-core/zip";
7
6
  // src/docx/metadataPrivacy.ts
8
7
  const FOLIO_DOCUMENT_METADATA_PROPERTIES = Object.freeze([
9
8
  "title",
@@ -57,40 +56,34 @@ const CORE_PROPERTIES_PATH_LOWER = "docprops/core.xml";
57
56
  * found — otherwise the privacy rewrite silently no-ops and the unscrubbed
58
57
  * buffer round-trips untouched.
59
58
  */
60
- const findZipEntryCaseInsensitive = (zip, lowerPath) => {
61
- const direct = zip.file(lowerPath);
62
- if (direct) return direct;
63
- for (const [path, file] of Object.entries(zip.files)) if (!file.dir && path.toLowerCase() === lowerPath) return file;
64
- return null;
65
- };
59
+ const findZipEntryCaseInsensitive = (zip, lowerPath) => zip.entry(lowerPath) ?? zip.entries().find(({ path }) => path.toLowerCase() === lowerPath) ?? null;
66
60
  const loadPrivacyArchive = async (buffer) => {
67
61
  if (buffer.byteLength > MAX_INPUT_BYTES) throw new FolioDocumentPrivacyArchiveError({
68
62
  message: "Document privacy input exceeded the compressed-size limit",
69
63
  reason: "input-too-large"
70
64
  });
71
- if (countCentralDirectoryRecords(new Uint8Array(buffer), MAX_ARCHIVE_ENTRIES) > MAX_ARCHIVE_ENTRIES) throw new FolioDocumentPrivacyArchiveError({
72
- message: "Document privacy input exceeded the package-entry limit",
73
- reason: "too-many-entries"
65
+ const read = await Result.tryPromise({
66
+ try: async () => await ZipArchive.read(buffer, { limits: {
67
+ maxEntries: MAX_ARCHIVE_ENTRIES,
68
+ maxCompressionRatio: 200
69
+ } }),
70
+ catch: (cause) => cause
74
71
  });
75
- let zip;
76
- try {
77
- zip = await JSZip.loadAsync(buffer);
78
- } catch (cause) {
72
+ if (Result.isError(read)) {
73
+ const cause = read.error;
74
+ if (cause instanceof ZipError && cause.reason === "too-many-entries") throw new FolioDocumentPrivacyArchiveError({
75
+ message: "Document privacy input exceeded the package-entry limit",
76
+ reason: "too-many-entries"
77
+ });
79
78
  throw new FolioDocumentPrivacyArchiveError({
80
79
  message: "Document privacy input is not a readable package",
81
80
  reason: "load-failed",
82
81
  cause
83
82
  });
84
83
  }
85
- if (Object.values(zip.files).length > MAX_ARCHIVE_ENTRIES) throw new FolioDocumentPrivacyArchiveError({
86
- message: "Document privacy input exceeded the package-entry limit",
87
- reason: "too-many-entries"
88
- });
84
+ const zip = read.value;
89
85
  const coreProperties = findZipEntryCaseInsensitive(zip, CORE_PROPERTIES_PATH_LOWER);
90
- if (!coreProperties) return zip;
91
- const data = "_data" in coreProperties ? coreProperties._data : void 0;
92
- const declaredBytes = typeof data === "object" && data !== null && "uncompressedSize" in data ? data.uncompressedSize : void 0;
93
- if (typeof declaredBytes !== "number" || declaredBytes > MAX_CORE_PROPERTIES_BYTES) throw new FolioDocumentPrivacyArchiveError({
86
+ if (coreProperties && coreProperties.size > MAX_CORE_PROPERTIES_BYTES) throw new FolioDocumentPrivacyArchiveError({
94
87
  message: "Document privacy core properties exceeded the part-size limit",
95
88
  reason: "core-properties-too-large"
96
89
  });
@@ -119,22 +112,26 @@ const rewriteCorePropertiesPrivacy = (xml, transforms) => {
119
112
  };
120
113
  };
121
114
  /**
122
- * Read the core-properties part within its size limit. The declared size was
123
- * checked when the archive loaded, but only the inflation itself can show that
124
- * the part is no larger than it claims.
115
+ * Read the core-properties part. Its declared size was checked when the
116
+ * archive loaded, and inflation stops at that size.
125
117
  */
126
118
  const readCoreProperties = async (entry) => {
127
- const result = await inflateEntryWithinLimits({
128
- entry,
129
- maxEntryBytes: MAX_CORE_PROPERTIES_BYTES,
130
- maxCompressionRatio: 200,
131
- budget: createInflationBudget(MAX_CORE_PROPERTIES_BYTES)
132
- });
133
- if (!result.ok) throw new FolioDocumentPrivacyArchiveError({
134
- message: "Document privacy core properties exceeded the part-size limit",
135
- reason: "core-properties-too-large"
119
+ const read = await Result.tryPromise({
120
+ try: async () => await entry.bytes(),
121
+ catch: (cause) => cause
136
122
  });
137
- return decodeText(result.bytes, { ignoreBOM: true });
123
+ if (Result.isError(read)) {
124
+ const cause = read.error;
125
+ throw new FolioDocumentPrivacyArchiveError(cause instanceof ZipError && cause.reason === "declared-size-exceeded" ? {
126
+ message: "Document privacy core properties exceeded the part-size limit",
127
+ reason: "core-properties-too-large"
128
+ } : {
129
+ message: "Document privacy input is not a readable package",
130
+ reason: "load-failed",
131
+ cause
132
+ });
133
+ }
134
+ return decodeText(read.value, { ignoreBOM: true });
138
135
  };
139
136
  /** Rewrite selected package metadata fields without changing other package parts. */
140
137
  const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
@@ -156,16 +153,9 @@ const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
156
153
  removedMetadataProperties: []
157
154
  }
158
155
  };
159
- writeZipPart({
160
- zip,
161
- path: coreProperties.name,
162
- data: encodeUtf8(rewritten.xml)
163
- });
156
+ zip.set(coreProperties.path, rewritten.xml);
164
157
  return {
165
- buffer: await zip.generateAsync({
166
- type: "arraybuffer",
167
- compression: "DEFLATE"
168
- }),
158
+ buffer: (await zip.generate()).buffer,
169
159
  privacyReport: {
170
160
  appliedTransforms,
171
161
  removedMetadataProperties: rewritten.removedMetadataProperties
@@ -1,4 +1,4 @@
1
- import JSZip from "jszip";
1
+ import { ZipArchive } from "@stll/docx-core/zip";
2
2
  /** Resolve an internal URI reference once, before comparing it with ZIP entries. */
3
3
  export declare const resolvePackageRelationshipTarget: (target: string, relsPath: string) => string | undefined;
4
4
  /**
@@ -13,7 +13,7 @@ export declare const resolvePackageRelationshipTarget: (target: string, relsPath
13
13
  */
14
14
  export declare const isUnsafePackagePath: (path: string) => boolean;
15
15
  /** Drop the entries whose path a save refuses. Every other part survives. */
16
- export declare const removeUnsafeEntries: (zip: JSZip) => void;
16
+ export declare const removeUnsafeEntries: (zip: ZipArchive) => void;
17
17
  /** What reconciliation removed to make the package internally consistent. */
18
18
  export type PackageReferenceRepair = {
19
19
  /** Parts a relationship named that the package does not hold. */
@@ -41,7 +41,7 @@ export type PackageReferenceRepair = {
41
41
  * already-reconciled package therefore reports nothing and changes nothing,
42
42
  * which is what the repack tests assert about their own output.
43
43
  */
44
- export declare const reconcilePackageReferences: (zip: JSZip, compressionLevel: number) => Promise<PackageReferenceRepair>;
44
+ export declare const reconcilePackageReferences: (zip: ZipArchive, compressionLevel: number) => Promise<PackageReferenceRepair>;
45
45
  /**
46
46
  * What {@link checkPackageIntegrity} found wrong with a package. Both fields
47
47
  * are empty for a package where every internal relationship resolves and
@@ -75,4 +75,4 @@ export type PackageIntegrityReport = {
75
75
  * or a corpus-gate invariant: one pass over the `.rels` parts building an id
76
76
  * census, one pass over the XML parts checking references against it.
77
77
  */
78
- export declare const checkPackageIntegrity: (zip: JSZip) => Promise<PackageIntegrityReport>;
78
+ export declare const checkPackageIntegrity: (zip: ZipArchive) => Promise<PackageIntegrityReport>;