@bendyline/squisq-formats 1.4.2 → 2.0.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.
Files changed (139) hide show
  1. package/README.md +48 -2
  2. package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
  3. package/dist/chunk-2CSZJEAL.js.map +1 -0
  4. package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
  5. package/dist/chunk-2EFDWKC6.js.map +1 -0
  6. package/dist/chunk-2PRVLAU3.js +740 -0
  7. package/dist/chunk-2PRVLAU3.js.map +1 -0
  8. package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
  9. package/dist/chunk-4HJ6AK2Q.js.map +1 -0
  10. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  11. package/dist/chunk-4VUWTSGM.js.map +1 -0
  12. package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
  13. package/dist/chunk-CVNVD7VH.js.map +1 -0
  14. package/dist/chunk-CX3LH47J.js +99 -0
  15. package/dist/chunk-CX3LH47J.js.map +1 -0
  16. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  17. package/dist/chunk-GVS2XXV6.js.map +1 -0
  18. package/dist/chunk-H7T5DXUU.js +479 -0
  19. package/dist/chunk-H7T5DXUU.js.map +1 -0
  20. package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
  21. package/dist/chunk-HBSCRS5P.js.map +1 -0
  22. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  23. package/dist/chunk-LXYLOOST.js.map +1 -0
  24. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  25. package/dist/chunk-MLX2BOJC.js.map +1 -0
  26. package/dist/chunk-NKAJPJ4G.js +106 -0
  27. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  28. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  29. package/dist/chunk-ODL3SSPT.js +18 -0
  30. package/dist/chunk-ODL3SSPT.js.map +1 -0
  31. package/dist/chunk-X4USWOQ2.js +131 -0
  32. package/dist/chunk-X4USWOQ2.js.map +1 -0
  33. package/dist/chunk-XTLQXR4U.js +195 -0
  34. package/dist/chunk-XTLQXR4U.js.map +1 -0
  35. package/dist/chunk-YRT7GQ5Y.js +28 -0
  36. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  37. package/dist/chunk-ZZ7D4DN6.js +97 -0
  38. package/dist/chunk-ZZ7D4DN6.js.map +1 -0
  39. package/dist/container/index.d.ts +6 -2
  40. package/dist/container/index.js +15 -17
  41. package/dist/container/index.js.map +1 -1
  42. package/dist/docx/index.d.ts +6 -2
  43. package/dist/docx/index.js +5 -3
  44. package/dist/epub/index.d.ts +3 -1
  45. package/dist/epub/index.js +3 -3
  46. package/dist/export-Djev9BLU.d.ts +69 -0
  47. package/dist/extract-LNOA7DBK.js +15 -0
  48. package/dist/extract-LNOA7DBK.js.map +1 -0
  49. package/dist/html/index.d.ts +15 -85
  50. package/dist/html/index.js +3 -3
  51. package/dist/images-7FBWPKE3.js +7 -0
  52. package/dist/images-7FBWPKE3.js.map +1 -0
  53. package/dist/import-DTkDxHmZ.d.ts +84 -0
  54. package/dist/import-Yivc3kZM.d.ts +136 -0
  55. package/dist/index.d.ts +13 -4
  56. package/dist/index.js +30 -11
  57. package/dist/infer/index.d.ts +138 -0
  58. package/dist/infer/index.js +28 -0
  59. package/dist/infer/index.js.map +1 -0
  60. package/dist/layouts-7OUUVAGU.js +12 -0
  61. package/dist/layouts-7OUUVAGU.js.map +1 -0
  62. package/dist/layouts-CZ0bdsNL.d.ts +150 -0
  63. package/dist/mapTheme-IR27S6IV.js +11 -0
  64. package/dist/mapTheme-IR27S6IV.js.map +1 -0
  65. package/dist/ooxml/index.d.ts +21 -127
  66. package/dist/ooxml/index.js +31 -9
  67. package/dist/pdf/index.d.ts +3 -1
  68. package/dist/pdf/index.js +1 -1
  69. package/dist/pptx/index.d.ts +8 -108
  70. package/dist/pptx/index.js +13 -4
  71. package/dist/reader-Gof6OrJX.d.ts +140 -0
  72. package/dist/registry/index.d.ts +46 -8
  73. package/dist/registry/index.js +5 -2
  74. package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
  75. package/dist/xlsx/index.d.ts +6 -66
  76. package/dist/xlsx/index.js +4 -2
  77. package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +167 -0
  81. package/src/__tests__/epub.test.ts +102 -1
  82. package/src/__tests__/html.test.ts +62 -0
  83. package/src/__tests__/inferTheme.test.ts +135 -0
  84. package/src/__tests__/lossyWarnings.test.ts +41 -0
  85. package/src/__tests__/ooxml.test.ts +59 -2
  86. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  87. package/src/__tests__/plainHtml.test.ts +15 -1
  88. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  89. package/src/__tests__/pptxImport.test.ts +30 -0
  90. package/src/__tests__/pptxInferFixtures.ts +314 -0
  91. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  92. package/src/__tests__/zipSafety.test.ts +317 -0
  93. package/src/container/index.ts +20 -29
  94. package/src/docx/export.ts +4 -2
  95. package/src/docx/import.ts +4 -3
  96. package/src/epub/export.ts +94 -25
  97. package/src/html/docsHtmlBundle.ts +6 -2
  98. package/src/html/htmlTemplate.ts +31 -6
  99. package/src/html/imageUtils.ts +14 -5
  100. package/src/html/index.ts +12 -1
  101. package/src/html/plainHtml.ts +7 -3
  102. package/src/html/plainHtmlBundle.ts +11 -9
  103. package/src/index.ts +20 -0
  104. package/src/infer/extract.ts +127 -0
  105. package/src/infer/index.ts +168 -0
  106. package/src/infer/mapTheme.ts +176 -0
  107. package/src/infer/types.ts +27 -0
  108. package/src/ooxml/index.ts +14 -0
  109. package/src/ooxml/readUtils.ts +44 -0
  110. package/src/ooxml/reader.ts +87 -28
  111. package/src/ooxml/themeReader.ts +197 -0
  112. package/src/ooxml/types.ts +8 -9
  113. package/src/ooxml/writer.ts +28 -10
  114. package/src/pdf/export.ts +4 -2
  115. package/src/pdf/import.ts +40 -34
  116. package/src/pptx/export.ts +4 -2
  117. package/src/pptx/import.ts +214 -32
  118. package/src/pptx/index.ts +14 -0
  119. package/src/pptx/layouts.ts +1168 -0
  120. package/src/registry/convert.ts +54 -23
  121. package/src/registry/defaultFormats.ts +131 -44
  122. package/src/registry/index.ts +3 -0
  123. package/src/registry/types.ts +52 -6
  124. package/src/shared/boundedZipArchive.ts +342 -0
  125. package/src/shared/fidelity.ts +130 -0
  126. package/src/shared/zipEntryCount.ts +97 -0
  127. package/src/shared/zipLimits.ts +286 -0
  128. package/src/shared/zipSafety.ts +19 -0
  129. package/src/xlsx/import.ts +3 -2
  130. package/dist/chunk-46FMDOWZ.js.map +0 -1
  131. package/dist/chunk-5LSSWZPU.js.map +0 -1
  132. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  133. package/dist/chunk-EHLGMSTE.js.map +0 -1
  134. package/dist/chunk-MJGRI6XR.js.map +0 -1
  135. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  136. package/dist/chunk-SHLPSADL.js.map +0 -1
  137. package/dist/chunk-WWBIKRNP.js.map +0 -1
  138. package/dist/chunk-YESS7VY3.js.map +0 -1
  139. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -0,0 +1,317 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import JSZip from 'jszip';
3
+ import { getPartXml, openPackage } from '../ooxml/reader';
4
+ import type { OoxmlPackage } from '../ooxml/types';
5
+ import { openBoundedZipArchive, validateZipArchive, ZipSafetyError } from '../shared/zipSafety';
6
+ import { declaredZipEntryCount } from '../shared/zipEntryCount';
7
+
8
+ interface InstrumentedStream {
9
+ pause(): InstrumentedStream;
10
+ }
11
+
12
+ interface StreamableEntry {
13
+ internalStream(type: 'uint8array'): InstrumentedStream;
14
+ }
15
+
16
+ async function makeZip(
17
+ files: Record<string, string | Uint8Array>,
18
+ compression: 'STORE' | 'DEFLATE' = 'DEFLATE',
19
+ ): Promise<Uint8Array> {
20
+ const zip = new JSZip();
21
+ for (const [path, data] of Object.entries(files)) zip.file(path, data);
22
+ return zip.generateAsync({ type: 'uint8array', compression });
23
+ }
24
+
25
+ function patchMemberMetadata(
26
+ source: Uint8Array,
27
+ path: string,
28
+ patch: { uncompressedSize?: number; crc32?: number },
29
+ ): Uint8Array {
30
+ const bytes = source.slice();
31
+ const view = new DataView(bytes.buffer);
32
+ let patchedCentral = false;
33
+
34
+ for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
35
+ const signature = view.getUint32(offset, true);
36
+ let nameOffset: number;
37
+ let nameLengthOffset: number;
38
+ let uncompressedSizeOffset: number;
39
+ let crcOffset: number;
40
+ if (signature === 0x02014b50) {
41
+ nameOffset = offset + 46;
42
+ nameLengthOffset = offset + 28;
43
+ uncompressedSizeOffset = offset + 24;
44
+ crcOffset = offset + 16;
45
+ } else if (signature === 0x04034b50) {
46
+ nameOffset = offset + 30;
47
+ nameLengthOffset = offset + 26;
48
+ uncompressedSizeOffset = offset + 22;
49
+ crcOffset = offset + 14;
50
+ } else {
51
+ continue;
52
+ }
53
+
54
+ const nameLength = view.getUint16(nameLengthOffset, true);
55
+ const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
56
+ if (name !== path) continue;
57
+ if (patch.uncompressedSize !== undefined) {
58
+ view.setUint32(uncompressedSizeOffset, patch.uncompressedSize, true);
59
+ }
60
+ if (patch.crc32 !== undefined) view.setUint32(crcOffset, patch.crc32, true);
61
+ if (signature === 0x02014b50) patchedCentral = true;
62
+ }
63
+
64
+ expect(patchedCentral).toBe(true);
65
+ return bytes;
66
+ }
67
+
68
+ function renameZipMember(source: Uint8Array, from: string, to: string): Uint8Array {
69
+ expect(to.length).toBe(from.length);
70
+ const bytes = source.slice();
71
+ const view = new DataView(bytes.buffer);
72
+ const replacement = new TextEncoder().encode(to);
73
+ let renamedCentral = false;
74
+ for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
75
+ const signature = view.getUint32(offset, true);
76
+ const isCentral = signature === 0x02014b50;
77
+ const isLocal = signature === 0x04034b50;
78
+ if (!isCentral && !isLocal) continue;
79
+ const nameLengthOffset = offset + (isCentral ? 28 : 26);
80
+ const nameOffset = offset + (isCentral ? 46 : 30);
81
+ const nameLength = view.getUint16(nameLengthOffset, true);
82
+ const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
83
+ if (name !== from) continue;
84
+ bytes.set(replacement, nameOffset);
85
+ if (isCentral) renamedCentral = true;
86
+ }
87
+ expect(renamedCentral).toBe(true);
88
+ return bytes;
89
+ }
90
+
91
+ describe('bounded JSZip reads', () => {
92
+ it('preflights duplicate central records before JSZip collapses their names', async () => {
93
+ const valid = await makeZip({ 'one.txt': '1', 'two.txt': '2' }, 'STORE');
94
+ const duplicate = renameZipMember(valid, 'two.txt', 'one.txt');
95
+ const parsed = await JSZip.loadAsync(duplicate);
96
+ expect(Object.values(parsed.files).filter((entry) => !entry.dir)).toHaveLength(1);
97
+
98
+ const blob = new Blob([duplicate.slice().buffer]);
99
+ await expect(openBoundedZipArchive(blob, { maxEntries: 1 })).rejects.toMatchObject({
100
+ code: 'too-many-entries',
101
+ limit: 1,
102
+ actual: 2,
103
+ });
104
+ });
105
+
106
+ it('counts directory-only central records toward the entry limit', async () => {
107
+ const zip = new JSZip();
108
+ zip.folder('one');
109
+ zip.folder('two');
110
+ const data = await zip.generateAsync({ type: 'uint8array' });
111
+ await expect(openBoundedZipArchive(data, { maxEntries: 1 })).rejects.toMatchObject({
112
+ code: 'too-many-entries',
113
+ actual: 2,
114
+ });
115
+ });
116
+
117
+ it('finds the EOCD record with a comment containing signature-like bytes', async () => {
118
+ const zip = new JSZip();
119
+ zip.file('one.txt', '1');
120
+ zip.file('two.txt', '2');
121
+ const data = await zip.generateAsync({
122
+ type: 'uint8array',
123
+ comment: 'looks like PK\u0005\u0006 but is only a comment',
124
+ });
125
+ expect(declaredZipEntryCount(data)).toBe(2);
126
+ });
127
+
128
+ it('reads ZIP64 record counts and falls back when ZIP64 metadata is ambiguous', () => {
129
+ const zip64 = new Uint8Array(98);
130
+ const view = new DataView(zip64.buffer);
131
+ view.setUint32(0, 0x06064b50, true);
132
+ view.setUint32(32, 3, true);
133
+ view.setUint32(56, 0x07064b50, true);
134
+ view.setUint32(64, 0, true);
135
+ view.setUint32(76, 0x06054b50, true);
136
+ view.setUint16(86, 0xffff, true);
137
+ expect(declaredZipEntryCount(zip64)).toBe(3);
138
+
139
+ zip64.fill(0, 56, 76);
140
+ expect(declaredZipEntryCount(zip64)).toBeUndefined();
141
+ });
142
+
143
+ it('maps Blob read failures to a structured invalid-archive error', async () => {
144
+ const unreadable = {
145
+ size: 10,
146
+ slice: () => unreadable,
147
+ arrayBuffer: () => Promise.reject(new Error('read failed')),
148
+ } as unknown as Blob;
149
+ await expect(openBoundedZipArchive(unreadable)).rejects.toMatchObject({
150
+ code: 'invalid-archive',
151
+ });
152
+ });
153
+
154
+ it('returns structured errors while preserving established messages', async () => {
155
+ const data = await makeZip({ 'large.txt': '12345' }, 'STORE');
156
+ let error: unknown;
157
+ try {
158
+ await openBoundedZipArchive(data, { maxEntryUncompressedBytes: 4 });
159
+ } catch (caught: unknown) {
160
+ error = caught;
161
+ }
162
+
163
+ expect(error).toBeInstanceOf(ZipSafetyError);
164
+ expect(error).toMatchObject({
165
+ name: 'ZipSafetyError',
166
+ code: 'entry-too-large',
167
+ path: 'large.txt',
168
+ limit: 4,
169
+ actual: 5,
170
+ });
171
+ expect((error as Error).message).toMatch(/exceeds 4 byte per-entry limit/);
172
+ });
173
+
174
+ it('rejects a high-ratio member from central-directory metadata', async () => {
175
+ const data = await makeZip({ 'repeated.bin': new Uint8Array(128 * 1024) });
176
+ await expect(openBoundedZipArchive(data, { maxCompressionRatio: 2 })).rejects.toMatchObject({
177
+ code: 'compression-ratio-exceeded',
178
+ path: 'repeated.bin',
179
+ limit: 2,
180
+ });
181
+ });
182
+
183
+ it('enforces the default compression-ratio ceiling against a single-member bomb', async () => {
184
+ const data = await makeZip({ 'zeros.bin': new Uint8Array(2 * 1024 * 1024) });
185
+ await expect(openBoundedZipArchive(data)).rejects.toMatchObject({
186
+ code: 'compression-ratio-exceeded',
187
+ path: 'zeros.bin',
188
+ limit: 1000,
189
+ });
190
+ });
191
+
192
+ it('halts an underdeclared member on its first emitted chunk without retaining it', async () => {
193
+ const valid = await makeZip({ 'bomb.txt': new Uint8Array(1024 * 1024) });
194
+ const forged = patchMemberMetadata(valid, 'bomb.txt', { uncompressedSize: 1 });
195
+ const archive = await openBoundedZipArchive(forged);
196
+
197
+ await expect(archive.read('bomb.txt')).rejects.toMatchObject({
198
+ code: 'size-mismatch',
199
+ path: 'bomb.txt',
200
+ });
201
+ expect(archive.emittedUncompressedBytes).toBe(0);
202
+ });
203
+
204
+ it('pauses every concurrent JSZip stream when one read breaches its bound', async () => {
205
+ const data = await makeZip(
206
+ {
207
+ 'one.bin': new Uint8Array(64 * 1024),
208
+ 'two.bin': new Uint8Array(64 * 1024),
209
+ },
210
+ 'STORE',
211
+ );
212
+ const archive = await openBoundedZipArchive(data);
213
+ const pauseSpies: Array<ReturnType<typeof vi.fn>> = [];
214
+
215
+ for (const metadata of archive.entries) {
216
+ const entry = metadata.entry as unknown as StreamableEntry;
217
+ const original = entry.internalStream.bind(entry);
218
+ entry.internalStream = ((type: 'uint8array') => {
219
+ const stream = original(type);
220
+ const pause = vi.fn(stream.pause.bind(stream));
221
+ stream.pause = pause;
222
+ pauseSpies.push(pause);
223
+ return stream;
224
+ }) as StreamableEntry['internalStream'];
225
+ }
226
+
227
+ const results = await Promise.allSettled([
228
+ archive.read('one.bin', 1),
229
+ archive.read('two.bin', 1),
230
+ ]);
231
+ expect(results.every((result) => result.status === 'rejected')).toBe(true);
232
+ expect(pauseSpies).toHaveLength(2);
233
+ expect(pauseSpies.every((pause) => pause.mock.calls.length > 0)).toBe(true);
234
+ expect(archive.emittedUncompressedBytes).toBe(0);
235
+ });
236
+
237
+ it('caches successful part reads without charging the aggregate budget twice', async () => {
238
+ const data = await makeZip({ 'part.xml': '<part />' }, 'STORE');
239
+ const archive = await openBoundedZipArchive(data);
240
+ const first = await archive.read('part.xml');
241
+ const emittedAfterFirstRead = archive.emittedUncompressedBytes;
242
+ const second = await archive.read('part.xml');
243
+
244
+ expect(second).toBe(first);
245
+ expect(archive.emittedUncompressedBytes).toBe(emittedAfterFirstRead);
246
+ expect(emittedAfterFirstRead).toBe(new TextEncoder().encode('<part />').byteLength);
247
+ });
248
+
249
+ it('checks CRC32 while streaming instead of eagerly inflating during load', async () => {
250
+ const valid = await makeZip({ 'content.txt': 'integrity' }, 'STORE');
251
+ const forged = patchMemberMetadata(valid, 'content.txt', { crc32: 0 });
252
+ const archive = await openBoundedZipArchive(forged);
253
+
254
+ await expect(archive.read('content.txt')).rejects.toMatchObject({
255
+ code: 'crc-mismatch',
256
+ path: 'content.txt',
257
+ });
258
+ });
259
+
260
+ it('does not reject an otherwise valid JSZip object solely for unavailable internals', async () => {
261
+ const data = await makeZip({ 'content.txt': 'okay' }, 'STORE');
262
+ const zip = await JSZip.loadAsync(data);
263
+ const entry = zip.file('content.txt') as unknown as {
264
+ _data: { uncompressedSize?: number; compressedSize?: number; crc32?: number };
265
+ };
266
+ delete entry._data.uncompressedSize;
267
+ delete entry._data.compressedSize;
268
+ delete entry._data.crc32;
269
+
270
+ expect(() => validateZipArchive(zip)).not.toThrow();
271
+ });
272
+
273
+ it('rejects manually constructed OOXML packages that bypass bounded archive opening', async () => {
274
+ const forged = {
275
+ contentTypes: { overrides: new Map(), defaults: new Map() },
276
+ rootRelationships: [],
277
+ } as unknown as OoxmlPackage;
278
+
279
+ await expect(getPartXml(forged, 'custom.xml')).rejects.toThrow(
280
+ 'Invalid OoxmlPackage: create packages with openPackage().',
281
+ );
282
+ });
283
+
284
+ it('bounds mandatory OOXML metadata independently of large media allowances', async () => {
285
+ const oversizedContentTypes = `<Types>${' '.repeat(1024 * 1024)}</Types>`;
286
+ const data = await makeZip(
287
+ {
288
+ '[Content_Types].xml': oversizedContentTypes,
289
+ 'word/media/large.bin': new Uint8Array(2 * 1024 * 1024),
290
+ },
291
+ 'STORE',
292
+ );
293
+
294
+ await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
295
+ code: 'entry-too-large',
296
+ path: '[Content_Types].xml',
297
+ limit: 1024 * 1024,
298
+ });
299
+ });
300
+
301
+ it('bounds OOXML relationship metadata before DOM parsing', async () => {
302
+ const oversizedRelationships = `<Relationships>${' '.repeat(4 * 1024 * 1024)}</Relationships>`;
303
+ const data = await makeZip(
304
+ {
305
+ '[Content_Types].xml': '<Types />',
306
+ '_rels/.rels': oversizedRelationships,
307
+ },
308
+ 'STORE',
309
+ );
310
+
311
+ await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
312
+ code: 'entry-too-large',
313
+ path: '_rels/.rels',
314
+ limit: 4 * 1024 * 1024,
315
+ });
316
+ });
317
+ });
@@ -14,6 +14,18 @@
14
14
  import JSZip from 'jszip';
15
15
  import type { ContentContainer } from '@bendyline/squisq/storage';
16
16
  import { MemoryContentContainer } from '@bendyline/squisq/storage';
17
+ import {
18
+ assertSafeZipPath,
19
+ openBoundedZipArchive,
20
+ ZipSafetyError,
21
+ type ZipSafetyLimits,
22
+ type ZipSafetyErrorCode,
23
+ type ZipSafetyErrorOptions,
24
+ } from '../shared/zipSafety.js';
25
+
26
+ export type ZipToContainerOptions = ZipSafetyLimits;
27
+ export { ZipSafetyError };
28
+ export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
17
29
 
18
30
  /**
19
31
  * Serialize a ContentContainer to a ZIP blob.
@@ -29,6 +41,7 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
29
41
  const entries = await container.listFiles();
30
42
 
31
43
  for (const entry of entries) {
44
+ assertSafeZipPath(entry.path);
32
45
  const data = await container.readFile(entry.path);
33
46
  if (data) {
34
47
  zip.file(entry.path, new Uint8Array(data));
@@ -54,36 +67,14 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
54
67
  */
55
68
  export async function zipToContainer(
56
69
  zipData: ArrayBuffer | Uint8Array | Blob,
70
+ options: ZipToContainerOptions = {},
57
71
  ): Promise<MemoryContentContainer> {
58
- const zip = await JSZip.loadAsync(zipData);
72
+ const archive = await openBoundedZipArchive(zipData, options);
59
73
  const container = new MemoryContentContainer();
60
-
61
- const filePromises: Promise<void>[] = [];
62
-
63
- zip.forEach((relativePath, zipEntry) => {
64
- // Skip directories
65
- if (zipEntry.dir) return;
66
-
67
- // Strip leading slash if present
68
- const entryPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
69
- if (!entryPath) return;
70
-
71
- // Path-traversal protection: reject absolute paths, backslashes, and .. segments
72
- if (
73
- entryPath.startsWith('/') ||
74
- entryPath.includes('\\') ||
75
- entryPath.split('/').some((seg) => seg === '..')
76
- ) {
77
- return;
78
- }
79
-
80
- filePromises.push(
81
- zipEntry.async('arraybuffer').then((data) => {
82
- return container.writeFile(entryPath, data);
83
- }),
84
- );
85
- });
86
-
87
- await Promise.all(filePromises);
74
+ for (const { path } of archive.entries) {
75
+ const data = await archive.read(path);
76
+ if (data) await container.writeFile(path, data);
77
+ archive.release(path);
78
+ }
88
79
  return container;
89
80
  }
@@ -17,7 +17,7 @@
17
17
  * ```
18
18
  */
19
19
 
20
- import type { Doc, Theme } from '@bendyline/squisq/schemas';
20
+ import type { Doc, Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
21
21
  import { resolveFontFamily } from '@bendyline/squisq/schemas';
22
22
  import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
23
23
  import type {
@@ -105,6 +105,8 @@ export interface DocxExportOptions {
105
105
  * the theme's primary color to headings.
106
106
  */
107
107
  themeId?: string;
108
+ /** Explicit caller-owned registry for non-document custom themes. */
109
+ themeRegistry?: ThemeRegistry;
108
110
  /**
109
111
  * Pre-resolved image data keyed by image URL/path as it appears in the
110
112
  * markdown source. When provided, images are embedded in the .docx file
@@ -221,7 +223,7 @@ class ExportContext {
221
223
  let themeBackgroundColor: string | undefined;
222
224
 
223
225
  if (options.themeId) {
224
- const theme: Theme = resolveThemeForDoc(doc, options.themeId);
226
+ const theme: Theme = resolveThemeForDoc(doc, options.themeId, options.themeRegistry);
225
227
  // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
226
228
  // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
227
229
  // single font name — passing the whole stack is treated as a
@@ -48,6 +48,7 @@ import type {
48
48
  } from '@bendyline/squisq/markdown';
49
49
 
50
50
  import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
51
+ import type { OoxmlOpenOptions } from '../ooxml/reader.js';
51
52
  import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
52
53
  import { NS_WML, NS_R } from '../ooxml/namespaces.js';
53
54
  import type { ContentContainer } from '@bendyline/squisq/storage';
@@ -68,7 +69,7 @@ import {
68
69
  /**
69
70
  * Options for DOCX import.
70
71
  */
71
- export interface DocxImportOptions {
72
+ export interface DocxImportOptions extends OoxmlOpenOptions {
72
73
  /**
73
74
  * Whether to extract embedded images as base64 data URIs.
74
75
  * When false, images are represented as `[Image]` placeholders.
@@ -88,7 +89,7 @@ export async function docxToMarkdownDoc(
88
89
  data: ArrayBuffer | Blob,
89
90
  options: DocxImportOptions = {},
90
91
  ): Promise<MarkdownDocument> {
91
- const pkg = await openPackage(data);
92
+ const pkg = await openPackage(data, options);
92
93
  const ctx = await buildImportContext(pkg, options);
93
94
 
94
95
  const documentXml = await getPartXml(pkg, 'word/document.xml');
@@ -138,7 +139,7 @@ export async function docxToContainer(
138
139
  data: ArrayBuffer | Blob,
139
140
  options: DocxImportOptions = {},
140
141
  ): Promise<ContentContainer> {
141
- const pkg = await openPackage(data);
142
+ const pkg = await openPackage(data, options);
142
143
  const ctx = await buildImportContext(pkg, { ...options, extractImages: true });
143
144
 
144
145
  const documentXml = await getPartXml(pkg, 'word/document.xml');
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import JSZip from 'jszip';
25
- import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
25
+ import type { Doc, AudioSegment, ThemeRegistry } from '@bendyline/squisq/schemas';
26
26
  import { resolveFontFamily } from '@bendyline/squisq/schemas';
27
27
  import { resolveThemeForDoc } from '@bendyline/squisq/doc';
28
28
  import type {
@@ -36,7 +36,7 @@ import type {
36
36
  MarkdownTableRow,
37
37
  MarkdownTableCell,
38
38
  } from '@bendyline/squisq/markdown';
39
- import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
39
+ import { readFrontmatterThemeId, sanitizeUrl } from '@bendyline/squisq/markdown';
40
40
  import { escapeXml } from '../ooxml/xmlUtils.js';
41
41
  import { inferMimeType, extractFilename } from '../html/imageUtils.js';
42
42
  import { extractPlainText } from '../shared/text.js';
@@ -56,6 +56,8 @@ export interface EpubExportOptions {
56
56
  publisher?: string;
57
57
  /** Squisq theme ID for CSS styling */
58
58
  themeId?: string;
59
+ /** Explicit caller-owned registry for non-document custom themes. */
60
+ themeRegistry?: ThemeRegistry;
59
61
  /** Pre-resolved image data keyed by relative path as it appears in the markdown */
60
62
  images?: Map<string, ArrayBuffer>;
61
63
  /** Cover image data (JPEG or PNG) */
@@ -93,7 +95,7 @@ export async function markdownDocToEpub(
93
95
  const language = options.language ?? 'en';
94
96
  const description = options.description ?? '';
95
97
  const publisher = options.publisher ?? '';
96
- const uuid = crypto.randomUUID();
98
+ const uuid = createEpubUuid();
97
99
 
98
100
  // Split document into chapters
99
101
  const chapters = splitIntoChapters(doc.children);
@@ -101,22 +103,13 @@ export async function markdownDocToEpub(
101
103
  // Collect images referenced in the document, deduplicating filenames
102
104
  const imageEntries = collectDocImages(doc.children);
103
105
  const resolvedImages = new Map<string, { data: ArrayBuffer; mime: string; filename: string }>();
106
+ const usedImageNames = new Set<string>();
104
107
  if (options.images) {
105
- const usedNames = new Set<string>();
106
108
  for (const src of imageEntries) {
107
109
  const data = options.images.get(src);
108
110
  if (data) {
109
- let filename = extractFilename(src);
110
- // Deduplicate: if two paths share a basename (e.g. a/hero.png and b/hero.png)
111
- if (usedNames.has(filename)) {
112
- const dot = filename.lastIndexOf('.');
113
- const base = dot > 0 ? filename.slice(0, dot) : filename;
114
- const ext = dot > 0 ? filename.slice(dot) : '';
115
- let counter = 2;
116
- while (usedNames.has(`${base}-${counter}${ext}`)) counter++;
117
- filename = `${base}-${counter}${ext}`;
118
- }
119
- usedNames.add(filename);
111
+ const filename = uniqueFilename(safeArchiveBasename(src, 'image'), usedImageNames);
112
+ usedImageNames.add(filename);
120
113
  resolvedImages.set(src, { data, mime: inferMimeType(filename), filename });
121
114
  }
122
115
  }
@@ -124,7 +117,11 @@ export async function markdownDocToEpub(
124
117
 
125
118
  // Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
126
119
  // theme (`squisq-theme` / legacy), mirroring the other export formats.
127
- const css = generateStylesheet(options.themeId ?? readFrontmatterThemeId(doc.frontmatter), doc);
120
+ const css = generateStylesheet(
121
+ options.themeId ?? readFrontmatterThemeId(doc.frontmatter),
122
+ doc,
123
+ options.themeRegistry,
124
+ );
128
125
 
129
126
  // Build the ZIP
130
127
  const zip = new JSZip();
@@ -155,7 +152,8 @@ export async function markdownDocToEpub(
155
152
  bytes[1] === 0x50 &&
156
153
  bytes[2] === 0x4e &&
157
154
  bytes[3] === 0x47;
158
- coverFilename = isPng ? 'cover.png' : 'cover.jpg';
155
+ coverFilename = uniqueFilename(isPng ? 'cover.png' : 'cover.jpg', usedImageNames);
156
+ usedImageNames.add(coverFilename);
159
157
  zip.file(`OEBPS/images/${coverFilename}`, options.coverImage);
160
158
 
161
159
  // Generate cover XHTML page — full-bleed image, no margins
@@ -184,17 +182,28 @@ export async function markdownDocToEpub(
184
182
  // Per-segment audio file info, indexed by segment index (null if data missing)
185
183
  const segmentAudioFiles: ({ filename: string; mime: string } | null)[] = [];
186
184
  const allAudioFiles: { filename: string; mime: string }[] = [];
185
+ const audioFilenameBySource = new Map<string, string>();
186
+ const usedAudioFilenames = new Set<string>();
187
187
 
188
188
  if (hasAudio) {
189
189
  for (const seg of audioSegments) {
190
190
  const data = audioMap.get(seg.src) ?? audioMap.get(seg.name);
191
191
  if (data) {
192
- const filename = extractFilename(seg.src);
193
- const finalName = filename.includes('.') ? filename : `${filename}.mp3`;
194
- zip.file(`OEBPS/audio/${finalName}`, data);
192
+ const sourceKey = seg.src || seg.name;
193
+ let finalName = audioFilenameBySource.get(sourceKey);
194
+ let isNewFile = false;
195
+ if (!finalName) {
196
+ const basename = safeArchiveBasename(sourceKey || 'narration', 'narration');
197
+ const withExtension = basename.includes('.') ? basename : `${basename}.mp3`;
198
+ finalName = uniqueFilename(withExtension, usedAudioFilenames);
199
+ audioFilenameBySource.set(sourceKey, finalName);
200
+ usedAudioFilenames.add(finalName);
201
+ zip.file(`OEBPS/audio/${finalName}`, data);
202
+ isNewFile = true;
203
+ }
195
204
  const info = { filename: finalName, mime: inferMimeType(finalName) };
196
205
  segmentAudioFiles.push(info);
197
- allAudioFiles.push(info);
206
+ if (isNewFile) allAudioFiles.push(info);
198
207
  } else {
199
208
  segmentAudioFiles.push(null);
200
209
  }
@@ -535,14 +544,18 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
535
544
  return `<code>${escapeXml(node.value)}</code>`;
536
545
 
537
546
  case 'link': {
547
+ const href = sanitizeUrl(node.url, 'link');
548
+ if (!href) return inlinesToXhtml(node.children, images);
538
549
  const titleAttr = node.title ? ` title="${escapeXml(node.title)}"` : '';
539
- return `<a href="${escapeXml(node.url)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
550
+ return `<a href="${escapeXml(href)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
540
551
  }
541
552
 
542
553
  case 'image': {
543
554
  const alt = escapeXml(node.alt ?? '');
544
555
  const resolved = images.get(node.url);
545
- const src = resolved ? `../images/${resolved.filename}` : escapeXml(node.url);
556
+ const safeSrc = resolved ? `../images/${resolved.filename}` : sanitizeUrl(node.url, 'media');
557
+ if (!safeSrc) return alt;
558
+ const src = escapeXml(safeSrc);
546
559
  return `<img src="${src}" alt="${alt}"/>`;
547
560
  }
548
561
 
@@ -561,6 +574,58 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
561
574
  }
562
575
  }
563
576
 
577
+ /**
578
+ * Produce an EPUB identifier without assuming `crypto.randomUUID()` exists.
579
+ * `randomUUID` is unavailable in older/non-secure browser contexts; use
580
+ * `getRandomValues` when possible and retain a non-cryptographic last resort
581
+ * because this UUID is an identifier, not a security token.
582
+ */
583
+ function createEpubUuid(): string {
584
+ const webCrypto = globalThis.crypto;
585
+ if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID();
586
+
587
+ const bytes = new Uint8Array(16);
588
+ if (typeof webCrypto?.getRandomValues === 'function') {
589
+ webCrypto.getRandomValues(bytes);
590
+ } else {
591
+ let seed = Date.now() ^ Math.floor(Math.random() * 0x7fffffff);
592
+ for (let i = 0; i < bytes.length; i++) {
593
+ seed = Math.imul(seed ^ (seed >>> 15), 1 | seed);
594
+ bytes[i] = seed & 0xff;
595
+ }
596
+ }
597
+ // RFC 4122 version 4 / variant 1 bits.
598
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
599
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
600
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
601
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
602
+ }
603
+
604
+ /** Flatten an external path to a safe archive basename. */
605
+ function safeArchiveBasename(path: string, fallback: string): string {
606
+ const filename = extractFilename(path.replace(/\\/g, '/'));
607
+ const safe = Array.from(filename, (character) => {
608
+ const codePoint = character.codePointAt(0) ?? 0;
609
+ return character === '\\' || character === '/' || codePoint < 0x20 || codePoint === 0x7f
610
+ ? '_'
611
+ : character;
612
+ })
613
+ .join('')
614
+ .trim();
615
+ return !safe || safe === '.' || safe === '..' ? fallback : safe;
616
+ }
617
+
618
+ /** Return a basename that does not collide with a prior archive member. */
619
+ function uniqueFilename(filename: string, used: ReadonlySet<string>): string {
620
+ if (!used.has(filename)) return filename;
621
+ const dot = filename.lastIndexOf('.');
622
+ const base = dot > 0 ? filename.slice(0, dot) : filename;
623
+ const ext = dot > 0 ? filename.slice(dot) : '';
624
+ let suffix = 2;
625
+ while (used.has(`${base}-${suffix}${ext}`)) suffix++;
626
+ return `${base}-${suffix}${ext}`;
627
+ }
628
+
564
629
  // ── SMIL Media Overlays ───────────────────────────────────────────
565
630
 
566
631
  /**
@@ -778,10 +843,14 @@ ${navItems}
778
843
 
779
844
  // ── Stylesheet ────────────────────────────────────────────────────
780
845
 
781
- function generateStylesheet(themeId: string | undefined, doc?: MarkdownDocument): string {
846
+ function generateStylesheet(
847
+ themeId: string | undefined,
848
+ doc?: MarkdownDocument,
849
+ registry?: ThemeRegistry,
850
+ ): string {
782
851
  let themeVars = '';
783
852
  if (themeId) {
784
- const theme = resolveThemeForDoc(doc, themeId);
853
+ const theme = resolveThemeForDoc(doc, themeId, registry);
785
854
  themeVars = `
786
855
  --epub-bg: ${theme.colors.background};
787
856
  --epub-text: ${theme.colors.text};