@stll/docx-utils 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,17 +1,12 @@
1
1
  # @stll/docx-utils
2
2
 
3
- Low-level helpers for reading and writing DOCX/OOXML zip packages.
4
-
5
- The package wraps the mechanical parts of working with a DOCX file: loading and
6
- repacking the underlying zip, extracting text or binary parts, managing
7
- relationships and content types, and the OOXML namespace constants those
8
- operations need.
3
+ Low-level helpers for OOXML packages: relationships and content types,
4
+ attached-template handling, office format metadata, and the namespace
5
+ constants those operations need. Load bounded archives with `loadDocx` or
6
+ `loadDocxArchive`; write archives with `@stll/docx-core/zip`.
9
7
 
10
8
  ```ts
11
- import { loadDocx, extractText, ensureRelationship } from "@stll/docx-utils";
12
-
13
- const zip = await loadDocx(bytes);
14
- const text = await extractText(zip, "word/document.xml");
9
+ import { ensureRelationship, OOXML_NS } from "@stll/docx-utils";
15
10
  ```
16
11
 
17
12
  ## Install
@@ -23,10 +18,13 @@ bun add @stll/docx-utils
23
18
  ## Exports
24
19
 
25
20
  - `OOXML_NS` / `OoxmlPrefix` — OOXML namespace constants and prefixes.
26
- - `loadDocx`, `repackZip`, `extractText`, `extractBinary`, `DOCX_COMPRESSION` —
27
- zip-level read and write helpers.
28
21
  - `findNextRId`, `ensureContentType`, `ensureRelationship` — relationship and
29
22
  content-type management.
23
+ - `classifyAttachedTemplateTarget`, `sanitizeAttachedTemplateRelationships`,
24
+ `sanitizeAttachedTemplateSource` — attached-template handling.
25
+
26
+ - `loadDocx`, `loadDocxArchive`, `DocxArchiveError` — shared bounded archive reads.
27
+ - `DOCX_MAX_ENTRIES`, `DOCX_MAX_ENTRY_BYTES`, `DOCX_MAX_TOTAL_BYTES` — archive limits.
30
28
 
31
29
  ## License
32
30
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import JSZip from "jszip";
2
- //#region src/namespaces.d.ts
1
+ import { ZipArchive } from "@stll/docx-core/zip";
3
2
  /**
4
3
  * Common OOXML namespace URIs.
5
4
  *
@@ -26,86 +25,12 @@ export declare const OOXML_NS: {
26
25
  readonly pr: "http://schemas.openxmlformats.org/package/2006/relationships";
27
26
  };
28
27
  type OoxmlPrefix = keyof typeof OOXML_NS;
29
- //#endregion
30
- //#region src/archive.d.ts
31
- /** Maximum bytes any single archive entry may decompress to. */
32
- export declare const DOCX_MAX_ENTRY_BYTES: number;
33
- /** Maximum cumulative uncompressed bytes the archive may yield. */
34
- export declare const DOCX_MAX_TOTAL_BYTES: number;
35
- /**
36
- * Maximum number of entries an archive may declare. Real DOCX/XLSX
37
- * documents have well under 200; orders of magnitude above that
38
- * indicate a hostile archive.
39
- */
40
- export declare const DOCX_MAX_ENTRIES = 4096;
41
- declare const DocxArchiveError_base: import("better-result").TaggedErrorClass<"DocxArchiveError">;
42
- export declare class DocxArchiveError extends DocxArchiveError_base<{
43
- message: string;
44
- reason: "load-failed" | "too-many-entries" | "entry-too-large" | "total-too-large" | "invalid-entry-name";
45
- cause?: unknown;
46
- }> {}
47
- type ArchiveOptions = {
48
- maxEntryBytes?: number;
49
- maxTotalBytes?: number;
50
- maxEntries?: number;
51
- };
52
- type DocxArchive = {
53
- /**
54
- * The underlying JSZip instance. Write entries with `writeZipEntry`
55
- * and repack with `zip.generateAsync(...)`; for reads, prefer the
56
- * bounded helpers on this object.
57
- */
58
- zip: JSZip;
59
- /**
60
- * Read an entry as UTF-8 text. Returns null if the entry is not in
61
- * the archive. Throws `DocxArchiveError` if the read would cross
62
- * the per-entry or cumulative archive cap.
63
- */
64
- readEntryString: (path: string) => Promise<string | null>;
65
- /** As {@link readEntryString} but returns the raw bytes. */
66
- readEntryUint8: (path: string) => Promise<Uint8Array | null>;
67
- };
68
- /** Load and validate an archive, preserving the promise-based ZIP API. */
69
- export declare const loadDocx: (buffer: ArrayBuffer | Uint8Array | Buffer, options?: ArchiveOptions) => Promise<JSZip>;
70
- export declare const loadDocxArchive: (buffer: ArrayBuffer | Uint8Array | Buffer, options?: ArchiveOptions) => Promise<DocxArchive>;
71
- //#endregion
72
- //#region src/zip.d.ts
73
- /** Standard DOCX compression options */
74
- export declare const DOCX_COMPRESSION: {
75
- type: "arraybuffer";
76
- compression: "DEFLATE";
77
- compressionOptions: {
78
- level: number;
79
- };
80
- };
81
- /** Extract a text file from a ZIP */
82
- export declare const extractText: (zip: JSZip, path: string) => Promise<string | null>;
83
- /** Extract a binary file from a ZIP */
84
- export declare const extractBinary: (zip: JSZip, path: string) => Promise<ArrayBuffer | null>;
85
- /** Repack a ZIP to ArrayBuffer with standard DOCX compression */
86
- export declare const repackZip: (zip: JSZip) => Promise<ArrayBuffer>;
87
- //#endregion
88
- //#region src/zip-entry.d.ts
89
- /** Text or bytes to store as one archive entry. */
90
- type ZipEntryContent = string | Uint8Array | ArrayBuffer;
91
- /**
92
- * Write `content` to `path` in `zip`. Text is UTF-8 encoded here, in one
93
- * pass over the whole string, before JSZip sees it: JSZip encodes string
94
- * input in 16 KiB pieces, which splits a surrogate pair that straddles a
95
- * piece boundary into two U+FFFD characters. Returns `zip`, like
96
- * `JSZip#file`, so writes can be chained.
97
- */
98
- export declare const writeZipEntry: (zip: JSZip, path: string, content: ZipEntryContent, options?: JSZip.JSZipFileOptions) => JSZip;
99
- //#endregion
100
- //#region src/relationships.d.ts
101
28
  /** Find the next available rId in a relationships XML string */
102
29
  export declare const findNextRId: (relsXml: string) => string;
103
30
  /** Ensure a content type entry exists in [Content_Types].xml */
104
31
  export declare const ensureContentType: (contentTypesXml: string, partName: string, contentType: string) => string;
105
32
  /** Ensure a relationship entry exists */
106
33
  export declare const ensureRelationship: (relsXml: string, rId: string, type: string, target: string) => string;
107
- //#endregion
108
- //#region src/attached-template.d.ts
109
34
  export declare const ATTACHED_TEMPLATE_SECURITY_RULE: "ooxml_attached_template";
110
35
  export declare const ATTACHED_TEMPLATE_TARGET_KIND: {
111
36
  readonly local: "local";
@@ -143,5 +68,44 @@ export declare const isOpcRelationshipPartPath: (path: string) => boolean;
143
68
  export declare const sanitizeAttachedTemplateRelationships: (xml: string, relationshipsPartPath: string) => SanitizedAttachedTemplateRelationships;
144
69
  /** Remove WordprocessingML references to the relationships removed above. */
145
70
  export declare const sanitizeAttachedTemplateSource: (xml: string, removedRelationshipIds: readonly string[]) => SanitizedAttachedTemplateSource;
146
- //#endregion
147
- export type { ArchiveOptions, AttachedTemplateRelationshipFinding, AttachedTemplateTargetKind, DocxArchive, OoxmlPrefix, ZipEntryContent };
71
+ /** Maximum bytes any single archive entry may decompress to. */
72
+ export declare const DOCX_MAX_ENTRY_BYTES: number;
73
+ /** Maximum cumulative uncompressed bytes the archive may yield. */
74
+ export declare const DOCX_MAX_TOTAL_BYTES: number;
75
+ /**
76
+ * Maximum number of entries an archive may declare. Real DOCX/XLSX
77
+ * documents have well under 200; orders of magnitude above that
78
+ * indicate a hostile archive.
79
+ */
80
+ export declare const DOCX_MAX_ENTRIES = 4096;
81
+ declare const DocxArchiveError_base: import("better-result").TaggedErrorClass<"DocxArchiveError">;
82
+ export declare class DocxArchiveError extends DocxArchiveError_base<{
83
+ message: string;
84
+ reason: "load-failed" | "too-many-entries" | "entry-too-large" | "total-too-large" | "compression-ratio-exceeded" | "invalid-entry-name";
85
+ cause?: unknown;
86
+ }> {}
87
+ type ArchiveOptions = {
88
+ maxEntryBytes?: number;
89
+ maxTotalBytes?: number;
90
+ maxEntries?: number;
91
+ };
92
+ type DocxArchive = {
93
+ /** The archive. Entry reads stay within its declared sizes. */
94
+ zip: ZipArchive;
95
+ /**
96
+ * Read an entry as UTF-8 text. Returns null if the entry is not in
97
+ * the archive. Throws `DocxArchiveError` if the read would cross
98
+ * the cumulative archive cap.
99
+ */
100
+ readEntryString: (path: string) => Promise<string | null>;
101
+ /** As {@link readEntryString} but returns the raw bytes. */
102
+ readEntryUint8: (path: string) => Promise<Uint8Array | null>;
103
+ };
104
+ /** Load a bounded archive through the shared promise-based contract. */
105
+ export declare const loadDocx: (buffer: ArrayBuffer | Uint8Array, options?: ArchiveOptions) => Promise<ZipArchive>;
106
+ /**
107
+ * Open an archive with {@link loadDocx} and read entries through a running
108
+ * total, so repeated reads stay within `maxTotalBytes` too.
109
+ */
110
+ export declare const loadDocxArchive: (buffer: ArrayBuffer | Uint8Array, options?: ArchiveOptions) => Promise<DocxArchive>;
111
+ export type { ArchiveOptions, AttachedTemplateRelationshipFinding, AttachedTemplateTargetKind, DocxArchive, OoxmlPrefix };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { Result, TaggedError } from "better-result";
2
- import JSZip from "jszip";
3
1
  import * as slimdom from "slimdom";
4
- //#region src/namespaces.ts
2
+ import { Result, TaggedError } from "better-result";
3
+ import { ZipArchive, ZipError } from "@stll/docx-core/zip";
4
+ // src/namespaces.ts
5
5
  /**
6
6
  * Common OOXML namespace URIs.
7
7
  *
@@ -27,389 +27,7 @@ const OOXML_NS = {
27
27
  ct: "http://schemas.openxmlformats.org/package/2006/content-types",
28
28
  pr: "http://schemas.openxmlformats.org/package/2006/relationships"
29
29
  };
30
- //#endregion
31
- //#region src/archive.ts
32
- /** Bounded metadata validation and sequential reads over OOXML archives. */
33
- /** Maximum bytes any single archive entry may decompress to. */
34
- const DOCX_MAX_ENTRY_BYTES = 134217728;
35
- /** Maximum cumulative uncompressed bytes the archive may yield. */
36
- const DOCX_MAX_TOTAL_BYTES = 268435456;
37
- /**
38
- * Maximum number of entries an archive may declare. Real DOCX/XLSX
39
- * documents have well under 200; orders of magnitude above that
40
- * indicate a hostile archive.
41
- */
42
- const DOCX_MAX_ENTRIES = 4096;
43
- var DocxArchiveError = class extends TaggedError("DocxArchiveError") {};
44
- const isStreamingEntry = (entry) => "internalStream" in entry && typeof entry.internalStream === "function";
45
- const readEntryBounded = async (entry, { maxEntryBytes, remainingBytes, onChunk }) => await new Promise((resolve, reject) => {
46
- if (!isStreamingEntry(entry)) {
47
- reject(new DocxArchiveError({
48
- message: "Failed to read DOCX entry",
49
- reason: "load-failed"
50
- }));
51
- return;
52
- }
53
- const stream = entry.internalStream("uint8array");
54
- let bytes = 0;
55
- let settled = false;
56
- stream.on("data", (chunk) => {
57
- if (settled) return;
58
- bytes += chunk.byteLength;
59
- if (bytes > maxEntryBytes || bytes > remainingBytes) {
60
- settled = true;
61
- stream.pause();
62
- reject(new DocxArchiveError({
63
- message: "DOCX entry exceeds its decompression budget",
64
- reason: bytes > maxEntryBytes ? "entry-too-large" : "total-too-large"
65
- }));
66
- return;
67
- }
68
- onChunk?.(chunk);
69
- });
70
- stream.on("error", (cause) => {
71
- if (!settled) {
72
- settled = true;
73
- reject(new DocxArchiveError({
74
- message: "Failed to read DOCX entry",
75
- reason: "load-failed",
76
- cause
77
- }));
78
- }
79
- });
80
- stream.on("end", () => {
81
- if (!settled) {
82
- settled = true;
83
- resolve(bytes);
84
- }
85
- });
86
- stream.resume();
87
- });
88
- const invalid = () => Result.err(new DocxArchiveError({
89
- message: "DOCX archive contains invalid entry metadata",
90
- reason: "load-failed"
91
- }));
92
- const limitError = (reason) => Result.err(new DocxArchiveError({
93
- message: "DOCX archive exceeds its budget",
94
- reason
95
- }));
96
- const readZip64Directory = (view, locator) => {
97
- if (view.getUint32(locator, true) !== 117853008 || view.getUint32(locator + 4, true) !== 0 || view.getUint32(locator + 16, true) !== 1) return invalid();
98
- const recordOffset = view.getBigUint64(locator + 8, true);
99
- if (recordOffset > BigInt(locator - 56)) return invalid();
100
- const record = Number(recordOffset);
101
- if (record < 0 || record + 56 > view.byteLength || view.getUint32(record, true) !== 101075792 || view.getBigUint64(record + 4, true) !== 44n || record + 56 !== locator) return invalid();
102
- if (view.getUint32(record + 16, true) !== 0 || view.getUint32(record + 20, true) !== 0) return invalid();
103
- const zipCount = view.getBigUint64(record + 32, true);
104
- const zipDiskCount = view.getBigUint64(record + 24, true);
105
- const zipSize = view.getBigUint64(record + 40, true);
106
- const zipStart = view.getBigUint64(record + 48, true);
107
- return Result.ok({
108
- count: zipCount,
109
- diskCount: zipDiskCount,
110
- size: zipSize,
111
- start: zipStart,
112
- directoryEnd: record
113
- });
114
- };
115
- const readDirectory = (view) => {
116
- const bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength);
117
- const end = bytes.lastIndexOf(new Uint8Array([
118
- 80,
119
- 75,
120
- 5,
121
- 6
122
- ]));
123
- if (end < Math.max(0, view.byteLength - 65535 - 22) || end > view.byteLength - 22 || end + 22 + view.getUint16(end + 20, true) !== view.byteLength) return invalid();
124
- if (view.getUint16(end + 4, true) !== 0 || view.getUint16(end + 6, true) !== 0) return invalid();
125
- let count = BigInt(view.getUint16(end + 10, true));
126
- let diskCount = BigInt(view.getUint16(end + 8, true));
127
- let size = BigInt(view.getUint32(end + 12, true));
128
- let start = BigInt(view.getUint32(end + 16, true));
129
- let directoryEnd = end;
130
- const locator = bytes.lastIndexOf(new Uint8Array([
131
- 80,
132
- 75,
133
- 6,
134
- 7
135
- ]));
136
- const needsZip64 = count === 65535n || diskCount === 65535n || size === 4294967295n || start === 4294967295n;
137
- if (!needsZip64 && locator !== -1) return invalid();
138
- if (needsZip64) {
139
- if (locator === -1 || locator !== end - 20) return invalid();
140
- const zip64 = readZip64Directory(view, locator);
141
- if (Result.isError(zip64)) return zip64;
142
- const actual = zip64.value;
143
- if ([
144
- [
145
- count,
146
- actual.count,
147
- 65535n
148
- ],
149
- [
150
- diskCount,
151
- actual.diskCount,
152
- 65535n
153
- ],
154
- [
155
- size,
156
- actual.size,
157
- 4294967295n
158
- ],
159
- [
160
- start,
161
- actual.start,
162
- 4294967295n
163
- ]
164
- ].some(([declared, value, sentinel]) => declared !== sentinel && declared !== value)) return invalid();
165
- ({count, diskCount, size, start, directoryEnd} = actual);
166
- }
167
- if (count !== diskCount || start + size !== BigInt(directoryEnd) || start > BigInt(directoryEnd)) return invalid();
168
- return Result.ok({
169
- count,
170
- diskCount,
171
- size,
172
- start,
173
- directoryEnd
174
- });
175
- };
176
- const readEntrySizes = ({ view, offset, extraStart, extraEndOffset }) => {
177
- let declared = BigInt(view.getUint32(offset + 24, true));
178
- let compressed = BigInt(view.getUint32(offset + 20, true));
179
- let localOffset = BigInt(view.getUint32(offset + 42, true));
180
- let extra = extraStart;
181
- let zip64Seen = false;
182
- while (extra < extraEndOffset) {
183
- if (extra + 4 > extraEndOffset) return invalid();
184
- const tag = view.getUint16(extra, true);
185
- const length = view.getUint16(extra + 2, true);
186
- const payload = extra + 4;
187
- if (payload + length > extraEndOffset) return invalid();
188
- if (tag === 1) {
189
- if (zip64Seen) return invalid();
190
- zip64Seen = true;
191
- if (length < (declared === 4294967295n ? 8 : 0) + (compressed === 4294967295n ? 8 : 0) + (localOffset === 4294967295n ? 8 : 0)) return invalid();
192
- let field = payload;
193
- if (declared === 4294967295n) {
194
- declared = view.getBigUint64(field, true);
195
- field += 8;
196
- }
197
- if (compressed === 4294967295n) {
198
- compressed = view.getBigUint64(field, true);
199
- field += 8;
200
- }
201
- if (localOffset === 4294967295n) localOffset = view.getBigUint64(field, true);
202
- }
203
- extra = payload + length;
204
- }
205
- if (!zip64Seen && (declared === 4294967295n || compressed === 4294967295n || localOffset === 4294967295n)) return invalid();
206
- return Result.ok({
207
- declared,
208
- compressed,
209
- localOffset
210
- });
211
- };
212
- const readEntryMetadata = ({ bytes, view, offset, start, directoryEnd }) => {
213
- if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 33639248) return invalid();
214
- const nameLength = view.getUint16(offset + 28, true);
215
- const extraLength = view.getUint16(offset + 30, true);
216
- const commentLength = view.getUint16(offset + 32, true);
217
- const next = offset + 46 + nameLength + extraLength + commentLength;
218
- if (next > directoryEnd || nameLength === 0 || view.getUint16(offset + 34, true) !== 0) return invalid();
219
- const name = Buffer.from(bytes.subarray(offset + 46, offset + 46 + nameLength)).toString("hex");
220
- const sizes = readEntrySizes({
221
- view,
222
- offset,
223
- extraStart: offset + 46 + nameLength,
224
- extraEndOffset: offset + 46 + nameLength + extraLength
225
- });
226
- if (Result.isError(sizes)) return sizes;
227
- const { declared, compressed, localOffset } = sizes.value;
228
- if (compressed > start || localOffset + 30n > start) return invalid();
229
- const local = Number(localOffset);
230
- if (local < 0 || local + 30 > bytes.length || view.getUint32(local, true) !== 67324752) return invalid();
231
- const localNameLength = view.getUint16(local + 26, true);
232
- if (localOffset + BigInt(30 + localNameLength + view.getUint16(local + 28, true)) + compressed > start || localNameLength !== nameLength || Buffer.from(bytes.subarray(local + 30, local + 30 + localNameLength)).toString("hex") !== name) return invalid();
233
- const directory = bytes[offset + 46 + nameLength - 1] === 47 || Math.floor(view.getUint32(offset + 38, true) / 16) % 2 === 1;
234
- return Result.ok({
235
- next,
236
- name,
237
- declared,
238
- directory
239
- });
240
- };
241
- const preflightArchive = (bytes, { maxEntries, maxEntryBytes, maxTotalBytes }) => {
242
- if ([
243
- maxEntries,
244
- maxEntryBytes,
245
- maxTotalBytes
246
- ].some((limit) => !Number.isSafeInteger(limit) || limit < 0)) return invalid();
247
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
248
- const metadata = readDirectory(view);
249
- if (Result.isError(metadata)) return metadata;
250
- const { count, start, directoryEnd } = metadata.value;
251
- let offset = Number(start);
252
- let records = 0;
253
- let total = 0n;
254
- const names = /* @__PURE__ */ new Set();
255
- while (offset < directoryEnd) {
256
- if (++records > maxEntries) return limitError("too-many-entries");
257
- const entry = readEntryMetadata({
258
- bytes,
259
- view,
260
- offset,
261
- start,
262
- directoryEnd
263
- });
264
- if (Result.isError(entry)) return entry;
265
- const { name, declared, directory, next } = entry.value;
266
- if (names.has(name)) return invalid();
267
- names.add(name);
268
- if (!directory) {
269
- if (declared > BigInt(maxEntryBytes)) return limitError("entry-too-large");
270
- total += declared;
271
- if (total > BigInt(maxTotalBytes)) return limitError("total-too-large");
272
- }
273
- offset = next;
274
- }
275
- if (BigInt(records) !== count) return invalid();
276
- return Result.ok();
277
- };
278
- const loadDocxResult = async (buffer, options = {}) => {
279
- const maxEntryBytes = options.maxEntryBytes ?? 134217728;
280
- const maxTotalBytes = options.maxTotalBytes ?? 268435456;
281
- const maxEntries = options.maxEntries ?? 4096;
282
- if (buffer.byteLength > 268435456) return Result.err(new DocxArchiveError({
283
- message: "DOCX archive exceeds the input byte limit",
284
- reason: "total-too-large"
285
- }));
286
- const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
287
- const preflight = preflightArchive(bytes, {
288
- maxEntries,
289
- maxEntryBytes,
290
- maxTotalBytes
291
- });
292
- if (Result.isError(preflight)) return preflight;
293
- const loaded = await Result.tryPromise({
294
- try: async () => await JSZip.loadAsync(buffer),
295
- catch: (cause) => new DocxArchiveError({
296
- message: "Failed to parse DOCX archive",
297
- reason: "load-failed",
298
- cause
299
- })
300
- });
301
- if (Result.isError(loaded)) return loaded;
302
- const zip = loaded.value;
303
- for (const entry of Object.values(zip.files)) {
304
- if (entry.dir) continue;
305
- const originalName = entry.unsafeOriginalName ?? entry.name;
306
- if (originalName.startsWith("/") || /^[a-z]:/iu.test(originalName) || originalName.includes("\\") || originalName.split("/").includes("..")) return Result.err(new DocxArchiveError({
307
- message: "DOCX archive contains an invalid entry name",
308
- reason: "invalid-entry-name"
309
- }));
310
- }
311
- let validatedBytes = 0;
312
- for (const entry of Object.values(zip.files)) {
313
- if (entry.dir) continue;
314
- const remainingBytes = maxTotalBytes - validatedBytes;
315
- const read = await Result.tryPromise({
316
- try: async () => await readEntryBounded(entry, {
317
- maxEntryBytes,
318
- remainingBytes
319
- }),
320
- catch: (cause) => cause instanceof DocxArchiveError ? cause : new DocxArchiveError({
321
- message: "Failed to read DOCX entry",
322
- reason: "load-failed",
323
- cause
324
- })
325
- });
326
- if (Result.isError(read)) return read;
327
- validatedBytes += read.value;
328
- }
329
- return Result.ok(zip);
330
- };
331
- /** Load and validate an archive, preserving the promise-based ZIP API. */
332
- const loadDocx = async (buffer, options = {}) => {
333
- const result = await loadDocxResult(buffer, options);
334
- if (Result.isError(result)) return await Promise.reject(result.error);
335
- return result.value;
336
- };
337
- const loadDocxArchive = async (buffer, options = {}) => {
338
- const maxEntryBytes = options.maxEntryBytes ?? 134217728;
339
- const maxTotalBytes = options.maxTotalBytes ?? 268435456;
340
- const zip = await loadDocx(buffer, options);
341
- let totalRead = 0;
342
- let readChain = Promise.resolve();
343
- const readEntry = async (path) => {
344
- const work = async () => {
345
- const entry = zip.file(path);
346
- if (!entry) return null;
347
- const remaining = maxTotalBytes - totalRead;
348
- const chunks = [];
349
- const size = await readEntryBounded(entry, {
350
- maxEntryBytes,
351
- remainingBytes: remaining,
352
- onChunk: (chunk) => {
353
- chunks.push(chunk);
354
- }
355
- });
356
- totalRead += size;
357
- return Buffer.concat(chunks, size);
358
- };
359
- const next = readChain.then(work, work);
360
- readChain = next.then(() => void 0, () => void 0);
361
- return await next;
362
- };
363
- return {
364
- zip,
365
- async readEntryString(path) {
366
- const buf = await readEntry(path);
367
- return buf === null ? null : buf.toString("utf-8");
368
- },
369
- async readEntryUint8(path) {
370
- const buf = await readEntry(path);
371
- return buf === null ? null : new Uint8Array(buf);
372
- }
373
- };
374
- };
375
- //#endregion
376
- //#region src/zip.ts
377
- /** Standard DOCX compression options */
378
- const DOCX_COMPRESSION = {
379
- type: "arraybuffer",
380
- compression: "DEFLATE",
381
- compressionOptions: { level: 6 }
382
- };
383
- /** Extract a text file from a ZIP */
384
- const extractText = async (zip, path) => {
385
- const file = zip.file(path);
386
- if (!file) return null;
387
- return await file.async("string");
388
- };
389
- /** Extract a binary file from a ZIP */
390
- const extractBinary = async (zip, path) => {
391
- const file = zip.file(path);
392
- if (!file) return null;
393
- return await file.async("arraybuffer");
394
- };
395
- /** Repack a ZIP to ArrayBuffer with standard DOCX compression */
396
- const repackZip = async (zip) => await zip.generateAsync(DOCX_COMPRESSION);
397
- //#endregion
398
- //#region src/zip-entry.ts
399
- const utf8 = new TextEncoder();
400
- /**
401
- * Write `content` to `path` in `zip`. Text is UTF-8 encoded here, in one
402
- * pass over the whole string, before JSZip sees it: JSZip encodes string
403
- * input in 16 KiB pieces, which splits a surrogate pair that straddles a
404
- * piece boundary into two U+FFFD characters. Returns `zip`, like
405
- * `JSZip#file`, so writes can be chained.
406
- */
407
- const writeZipEntry = (zip, path, content, options) => {
408
- const data = typeof content === "string" ? utf8.encode(content) : content;
409
- return zip.file(path, data, options);
410
- };
411
- //#endregion
412
- //#region src/relationships.ts
30
+ // src/relationships.ts
413
31
  /**
414
32
  * Escape a string for safe use inside a double-quoted XML attribute value.
415
33
  * Order matters: `&` must be escaped first so it does not double-escape the
@@ -449,8 +67,7 @@ const ensureRelationship = (relsXml, rId, type, target) => {
449
67
  const rel = `<Relationship Id="${escapedRId}" Type="${escapeXmlAttribute(type)}" Target="${escapeXmlAttribute(target)}"/>`;
450
68
  return relsXml.replace("</Relationships>", () => `${rel}\n</Relationships>`);
451
69
  };
452
- //#endregion
453
- //#region src/attached-template.ts
70
+ // src/attached-template.ts
454
71
  const ATTACHED_TEMPLATE_SECURITY_RULE = "ooxml_attached_template";
455
72
  const ATTACHED_TEMPLATE_TARGET_KIND = {
456
73
  local: "local",
@@ -572,5 +189,148 @@ const sanitizeAttachedTemplateSource = (xml, removedRelationshipIds) => {
572
189
  xml: slimdom.serializeToWellFormedString(document)
573
190
  };
574
191
  };
575
- //#endregion
576
- export { ATTACHED_TEMPLATE_SECURITY_RULE, ATTACHED_TEMPLATE_TARGET_KIND, DOCX_COMPRESSION, DOCX_MAX_ENTRIES, DOCX_MAX_ENTRY_BYTES, DOCX_MAX_TOTAL_BYTES, DocxArchiveError, OOXML_NS, classifyAttachedTemplateTarget, ensureContentType, ensureRelationship, extractBinary, extractText, findNextRId, isOpcRelationshipPartPath, loadDocx, loadDocxArchive, relationshipSourcePartPath, repackZip, sanitizeAttachedTemplateRelationships, sanitizeAttachedTemplateSource, writeZipEntry };
192
+ // src/archive.ts
193
+ /**
194
+ * Bounded reads of OOXML archives (DOCX, XLSX, PPTX) for OOXML consumers.
195
+ *
196
+ * `ZipArchive.read` checks every size, name and record an archive declares
197
+ * before anything inflates, and each entry inflates no further than its
198
+ * declared size. The limits here are the shared owner's policy on top of that.
199
+ */
200
+ /** Maximum bytes any single archive entry may decompress to. */
201
+ const DOCX_MAX_ENTRY_BYTES = 134217728;
202
+ /** Maximum cumulative uncompressed bytes the archive may yield. */
203
+ const DOCX_MAX_TOTAL_BYTES = 268435456;
204
+ /**
205
+ * Maximum number of entries an archive may declare. Real DOCX/XLSX
206
+ * documents have well under 200; orders of magnitude above that
207
+ * indicate a hostile archive.
208
+ */
209
+ const DOCX_MAX_ENTRIES = 4096;
210
+ /**
211
+ * Inflated bytes allowed per compressed byte for a markup entry, and for the
212
+ * archive as a whole, once either passes 4 MiB. Binary parts (an uncompressed
213
+ * bitmap of one colour) can legitimately compress further; the byte limits
214
+ * bound them.
215
+ */
216
+ const MAX_MARKUP_COMPRESSION_RATIO = 200;
217
+ const COMPRESSION_RATIO_GRACE_BYTES = 4194304;
218
+ const MARKUP_EXTENSIONS = /* @__PURE__ */ new Set([
219
+ "xml",
220
+ "rels",
221
+ "vml",
222
+ "txt",
223
+ "htm",
224
+ "html"
225
+ ]);
226
+ var DocxArchiveError = class extends TaggedError("DocxArchiveError") {};
227
+ const REASONS = {
228
+ malformed: "load-failed",
229
+ unsupported: "load-failed",
230
+ "duplicate-entry": "load-failed",
231
+ "corrupt-entry": "load-failed",
232
+ "invalid-entry-name": "invalid-entry-name",
233
+ "too-many-entries": "too-many-entries",
234
+ "entry-too-large": "entry-too-large",
235
+ "declared-size-exceeded": "entry-too-large",
236
+ "total-too-large": "total-too-large",
237
+ "compression-ratio": "compression-ratio-exceeded"
238
+ };
239
+ const toArchiveError = (cause) => cause instanceof DocxArchiveError ? cause : new DocxArchiveError({
240
+ message: cause instanceof ZipError ? cause.message : "Failed to parse DOCX archive",
241
+ reason: cause instanceof ZipError ? REASONS[cause.reason] : "load-failed",
242
+ cause
243
+ });
244
+ const isMarkup = (path) => MARKUP_EXTENSIONS.has(path.slice(path.lastIndexOf(".") + 1).toLowerCase());
245
+ /** Load a bounded archive through the shared promise-based contract. */
246
+ const loadDocx = async (buffer, options = {}) => {
247
+ const result = await loadDocxResult(buffer, options);
248
+ return Result.isError(result) ? await Promise.reject(result.error) : result.value;
249
+ };
250
+ /**
251
+ * Open an archive within the shared limits and inflate every entry once, so
252
+ * a corrupt or mis-declared entry is refused here rather than at a later
253
+ * read. Rejects with `DocxArchiveError`.
254
+ */
255
+ const loadDocxResult = async (buffer, options = {}) => {
256
+ const maxTotalBytes = options.maxTotalBytes ?? 268435456;
257
+ const maxEntryBytes = options.maxEntryBytes ?? 134217728;
258
+ const maxEntries = options.maxEntries ?? 4096;
259
+ if ([
260
+ maxEntries,
261
+ maxEntryBytes,
262
+ maxTotalBytes
263
+ ].some((limit) => !Number.isSafeInteger(limit) || limit < 0)) return Result.err(new DocxArchiveError({
264
+ message: "DOCX archive contains invalid entry metadata",
265
+ reason: "load-failed"
266
+ }));
267
+ if (buffer.byteLength > 268435456) return Result.err(new DocxArchiveError({
268
+ message: "DOCX archive exceeds the input byte limit",
269
+ reason: "total-too-large"
270
+ }));
271
+ const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
272
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
273
+ const earliest = Math.max(0, bytes.length - 65535 - 22);
274
+ const trailer = bytes.subarray(earliest);
275
+ const marker = trailer.findLastIndex((byte, index) => byte === 80 && trailer[index + 1] === 75 && trailer[index + 2] === 5 && trailer[index + 3] === 6);
276
+ const end = earliest + marker;
277
+ if (marker === -1 || end > bytes.length - 22 || end + 22 + view.getUint16(end + 20, true) !== bytes.length) return Result.err(new DocxArchiveError({
278
+ message: "DOCX archive contains invalid entry metadata",
279
+ reason: "load-failed"
280
+ }));
281
+ return await Result.tryPromise({
282
+ try: async () => {
283
+ const zip = await ZipArchive.read(buffer, {
284
+ limits: {
285
+ maxEntries,
286
+ maxEntryBytes,
287
+ maxTotalBytes,
288
+ maxCompressionRatio: MAX_MARKUP_COMPRESSION_RATIO,
289
+ compressionRatioGraceBytes: COMPRESSION_RATIO_GRACE_BYTES
290
+ },
291
+ isCompressionRatioExempt: (path) => !isMarkup(path)
292
+ });
293
+ for (const entry of zip.entries()) await entry.bytes();
294
+ return zip;
295
+ },
296
+ catch: toArchiveError
297
+ });
298
+ };
299
+ /**
300
+ * Open an archive with {@link loadDocx} and read entries through a running
301
+ * total, so repeated reads stay within `maxTotalBytes` too.
302
+ */
303
+ const loadDocxArchive = async (buffer, options = {}) => {
304
+ const maxTotalBytes = options.maxTotalBytes ?? 268435456;
305
+ const maxEntryBytes = options.maxEntryBytes ?? 134217728;
306
+ const zip = await loadDocx(buffer, options);
307
+ let totalRead = 0;
308
+ const readEntry = async (path) => {
309
+ const entry = zip.entry(path);
310
+ if (!entry) return null;
311
+ if (entry.size > maxEntryBytes || totalRead + entry.size > maxTotalBytes) return await Promise.reject(new DocxArchiveError({
312
+ message: "DOCX entry exceeds its decompression budget",
313
+ reason: entry.size > maxEntryBytes ? "entry-too-large" : "total-too-large"
314
+ }));
315
+ const reservedBytes = entry.size;
316
+ totalRead += reservedBytes;
317
+ const bytes = await Result.tryPromise({
318
+ try: async () => await entry.bytes(),
319
+ catch: toArchiveError
320
+ });
321
+ if (Result.isError(bytes)) {
322
+ totalRead -= reservedBytes;
323
+ return await Promise.reject(bytes.error);
324
+ }
325
+ return bytes.value;
326
+ };
327
+ return {
328
+ zip,
329
+ async readEntryString(path) {
330
+ const bytes = await readEntry(path);
331
+ return bytes === null ? null : new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
332
+ },
333
+ readEntryUint8: readEntry
334
+ };
335
+ };
336
+ export { ATTACHED_TEMPLATE_SECURITY_RULE, ATTACHED_TEMPLATE_TARGET_KIND, DOCX_MAX_ENTRIES, DOCX_MAX_ENTRY_BYTES, DOCX_MAX_TOTAL_BYTES, DocxArchiveError, OOXML_NS, classifyAttachedTemplateTarget, ensureContentType, ensureRelationship, findNextRId, isOpcRelationshipPartPath, loadDocx, loadDocxArchive, relationshipSourcePartPath, sanitizeAttachedTemplateRelationships, sanitizeAttachedTemplateSource };
@@ -1,4 +1,3 @@
1
- //#region src/office-formats.d.ts
2
1
  /** ZIP-based office formats; legacy binary formats are not XML containers. */
3
2
  export declare const OFFICE_ARCHIVE_FORMATS: {
4
3
  readonly docx: {
@@ -85,5 +84,4 @@ export declare const OFFICE_ARCHIVE_FORMATS: {
85
84
  readonly family: "odf";
86
85
  readonly mimeType: "application/vnd.oasis.opendocument.presentation-template";
87
86
  };
88
- };
89
- //#endregion
87
+ };
@@ -1,4 +1,4 @@
1
- //#region src/office-formats.ts
1
+ // src/office-formats.ts
2
2
  /** ZIP-based office formats; legacy binary formats are not XML containers. */
3
3
  const OFFICE_ARCHIVE_FORMATS = {
4
4
  docx: {
@@ -86,5 +86,4 @@ const OFFICE_ARCHIVE_FORMATS = {
86
86
  mimeType: "application/vnd.oasis.opendocument.presentation-template"
87
87
  }
88
88
  };
89
- //#endregion
90
89
  export { OFFICE_ARCHIVE_FORMATS };
@@ -1,5 +1,4 @@
1
1
  import { Attr, Document, Element } from "slimdom";
2
- //#region src/office-metadata.d.ts
3
2
  export declare const officeIdentityAttributePolicy: (name: string) => {
4
3
  readonly type: "replace";
5
4
  readonly replacement: "Author";
@@ -34,5 +33,4 @@ export type OfficeIdentityField = {
34
33
  field: string;
35
34
  };
36
35
  /** Internal reference IDs and document/comment body text are deliberately excluded. */
37
- export declare const officeIdentityFields: (document: Document, part: string) => OfficeIdentityField[];
38
- //#endregion
36
+ export declare const officeIdentityFields: (document: Document, part: string) => OfficeIdentityField[];
@@ -1,4 +1,4 @@
1
- //#region src/office-metadata.ts
1
+ // src/office-metadata.ts
2
2
  const PROPERTY_FIELDS = /* @__PURE__ */ new Set([
3
3
  "creator",
4
4
  "lastmodifiedby",
@@ -84,5 +84,4 @@ const officeIdentityFields = (document, part) => {
84
84
  }
85
85
  return result;
86
86
  };
87
- //#endregion
88
87
  export { isOfficeCollaborationPart, isOfficeIdentityPart, officeIdentityAttributePolicy, officeIdentityFields };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stll/docx-utils",
3
- "version": "0.2.0",
4
- "description": "Low-level helpers for reading and writing DOCX/OOXML zip packages: text and binary extraction, relationship and content-type management, and namespace constants.",
3
+ "version": "0.3.0",
4
+ "description": "Low-level OOXML helpers: relationship and content-type management, attached-template handling, office format metadata, and namespace constants.",
5
5
  "keywords": [
6
6
  "docx",
7
7
  "ooxml",
@@ -53,21 +53,21 @@
53
53
  "typecheck": "bun check --no-pretty --all --project=tsconfig.json",
54
54
  "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --type-aware --type-check packages/docx-utils",
55
55
  "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/docx-utils",
56
- "format": "bun ../../scripts/run-oxfmt.ts .",
57
56
  "prepack": "bun run build",
58
57
  "test:property": "bun test --preload @stll/property-testing/preload $(grep -rlE 'fc\\.assert|assertProperty' src --include='*.test.ts')"
59
58
  },
60
59
  "dependencies": {
60
+ "@stll/docx-core": "0.41.0",
61
61
  "better-result": "3.0.1",
62
- "jszip": "3.10.2",
63
62
  "slimdom": "^4.3.5"
64
63
  },
65
64
  "devDependencies": {
66
65
  "@stll/property-testing": "0.0.0",
67
66
  "@stll/typescript-config": "0.0.0",
68
- "bun-types": "1.4.3",
67
+ "bun-types": "1.4.4",
69
68
  "fast-check": "^4.10.2",
70
- "tsdown": "0.23.0"
69
+ "tsdown": "0.23.4",
70
+ "tsrs": "0.20.1-ts7.1.0-beta"
71
71
  },
72
72
  "main": "./dist/index.js",
73
73
  "types": "./dist/index.d.ts"