@bendyline/squisq-formats 1.4.1 → 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.
- package/README.md +48 -2
- package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
- package/dist/chunk-2CSZJEAL.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
- package/dist/chunk-2EFDWKC6.js.map +1 -0
- package/dist/chunk-2PRVLAU3.js +740 -0
- package/dist/chunk-2PRVLAU3.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
- package/dist/chunk-4HJ6AK2Q.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
- package/dist/chunk-CVNVD7VH.js.map +1 -0
- package/dist/chunk-CX3LH47J.js +99 -0
- package/dist/chunk-CX3LH47J.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/chunk-H7T5DXUU.js +479 -0
- package/dist/chunk-H7T5DXUU.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
- package/dist/chunk-HBSCRS5P.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-X4USWOQ2.js +131 -0
- package/dist/chunk-X4USWOQ2.js.map +1 -0
- package/dist/chunk-XTLQXR4U.js +195 -0
- package/dist/chunk-XTLQXR4U.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/chunk-ZZ7D4DN6.js +97 -0
- package/dist/chunk-ZZ7D4DN6.js.map +1 -0
- package/dist/container/index.d.ts +6 -2
- package/dist/container/index.js +15 -17
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-Djev9BLU.d.ts +69 -0
- package/dist/extract-LNOA7DBK.js +15 -0
- package/dist/extract-LNOA7DBK.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-Yivc3kZM.d.ts +136 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +30 -11
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-7OUUVAGU.js +12 -0
- package/dist/layouts-7OUUVAGU.js.map +1 -0
- package/dist/layouts-CZ0bdsNL.d.ts +150 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-Gof6OrJX.d.ts +140 -0
- package/dist/registry/index.d.ts +46 -8
- package/dist/registry/index.js +5 -2
- package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +167 -0
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +59 -2
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +20 -29
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +20 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +168 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +87 -28
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1168 -0
- package/src/registry/convert.ts +54 -23
- package/src/registry/defaultFormats.ts +131 -44
- package/src/registry/index.ts +3 -0
- package/src/registry/types.ts +52 -6
- package/src/shared/boundedZipArchive.ts +342 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +97 -0
- package/src/shared/zipLimits.ts +286 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/** Incremental JSZip member reads governed by one shared archive budget. */
|
|
2
|
+
|
|
3
|
+
import JSZip from 'jszip';
|
|
4
|
+
import {
|
|
5
|
+
archiveTooLargeError,
|
|
6
|
+
assertNonNegativeFinite,
|
|
7
|
+
compressionRatio,
|
|
8
|
+
compressionRatioError,
|
|
9
|
+
entryTooLargeError,
|
|
10
|
+
resolveZipSafetyLimits,
|
|
11
|
+
validateZipArchive,
|
|
12
|
+
tooManyEntriesError,
|
|
13
|
+
ZipSafetyError,
|
|
14
|
+
type ResolvedZipSafetyLimits,
|
|
15
|
+
type ValidatedZipEntry,
|
|
16
|
+
type ZipSafetyErrorCode,
|
|
17
|
+
type ZipSafetyLimits,
|
|
18
|
+
} from './zipLimits.js';
|
|
19
|
+
import { declaredZipEntryCount, prepareZipInput, type ZipInput } from './zipEntryCount.js';
|
|
20
|
+
|
|
21
|
+
interface ActiveZipRead {
|
|
22
|
+
stop(error: ZipSafetyError): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ZipChunkStream {
|
|
26
|
+
on(event: 'data', callback: (chunk: Uint8Array) => void): ZipChunkStream;
|
|
27
|
+
on(event: 'error', callback: (error: unknown) => void): ZipChunkStream;
|
|
28
|
+
on(event: 'end', callback: () => void): ZipChunkStream;
|
|
29
|
+
pause(): ZipChunkStream;
|
|
30
|
+
resume(): ZipChunkStream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StreamableZipObject {
|
|
34
|
+
internalStream(type: 'uint8array'): ZipChunkStream;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A JSZip archive whose member reads share one actual-byte budget.
|
|
39
|
+
*
|
|
40
|
+
* JSZip's promise API accumulates a complete inflated member before returning.
|
|
41
|
+
* This wrapper instead consumes `internalStream('uint8array')`, checks every
|
|
42
|
+
* emitted chunk, and pauses every active stream as soon as any limit fails.
|
|
43
|
+
* Completed reads are cached so repeated OOXML part access is neither inflated
|
|
44
|
+
* nor charged twice.
|
|
45
|
+
*/
|
|
46
|
+
export interface BoundedZipArchive {
|
|
47
|
+
readonly zip: JSZip;
|
|
48
|
+
readonly entries: readonly ValidatedZipEntry[];
|
|
49
|
+
readonly emittedUncompressedBytes: number;
|
|
50
|
+
read(path: string, maxBytes?: number): Promise<Uint8Array | null>;
|
|
51
|
+
readText(path: string, maxBytes?: number): Promise<string | null>;
|
|
52
|
+
release(path: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class JsZipBoundedArchive implements BoundedZipArchive {
|
|
56
|
+
readonly zip: JSZip;
|
|
57
|
+
readonly entries: readonly ValidatedZipEntry[];
|
|
58
|
+
|
|
59
|
+
private readonly limits: ResolvedZipSafetyLimits;
|
|
60
|
+
private readonly entriesByPath: ReadonlyMap<string, ValidatedZipEntry>;
|
|
61
|
+
private readonly reads = new Map<string, Promise<Uint8Array>>();
|
|
62
|
+
private readonly activeReads = new Set<ActiveZipRead>();
|
|
63
|
+
private terminalError?: ZipSafetyError;
|
|
64
|
+
private emittedBytes = 0;
|
|
65
|
+
|
|
66
|
+
constructor(zip: JSZip, entries: readonly ValidatedZipEntry[], limits: ResolvedZipSafetyLimits) {
|
|
67
|
+
this.zip = zip;
|
|
68
|
+
this.entries = entries;
|
|
69
|
+
this.limits = limits;
|
|
70
|
+
this.entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Actual uncompressed bytes accepted from JSZip streams so far. */
|
|
74
|
+
get emittedUncompressedBytes(): number {
|
|
75
|
+
return this.emittedBytes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read and cache one member, returning null when it does not exist. */
|
|
79
|
+
async read(path: string, maxBytes?: number): Promise<Uint8Array | null> {
|
|
80
|
+
this.throwIfFailed();
|
|
81
|
+
if (maxBytes !== undefined) assertNonNegativeFinite('member read limit', maxBytes);
|
|
82
|
+
const entry = this.entriesByPath.get(path);
|
|
83
|
+
if (!entry) return null;
|
|
84
|
+
|
|
85
|
+
let pending = this.reads.get(path);
|
|
86
|
+
if (!pending) {
|
|
87
|
+
pending = this.streamEntry(entry, maxBytes);
|
|
88
|
+
this.reads.set(path, pending);
|
|
89
|
+
}
|
|
90
|
+
const bytes = await pending;
|
|
91
|
+
if (maxBytes !== undefined && bytes.byteLength > maxBytes) {
|
|
92
|
+
const error = entryTooLargeError(path, bytes.byteLength, maxBytes);
|
|
93
|
+
this.failArchive(error);
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return bytes;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read one UTF-8 text member, returning null when it does not exist. */
|
|
100
|
+
async readText(path: string, maxBytes?: number): Promise<string | null> {
|
|
101
|
+
const bytes = await this.read(path, maxBytes);
|
|
102
|
+
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Release a cached member when a sequential consumer has taken ownership. */
|
|
106
|
+
release(path: string): void {
|
|
107
|
+
this.reads.delete(path);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private streamEntry(metadata: ValidatedZipEntry, readLimit?: number): Promise<Uint8Array> {
|
|
111
|
+
const { path, entry, declaredSize, crc32: expectedCrc } = metadata;
|
|
112
|
+
const effectiveEntryLimit = Math.min(
|
|
113
|
+
this.limits.maxEntryUncompressedBytes,
|
|
114
|
+
readLimit ?? Number.POSITIVE_INFINITY,
|
|
115
|
+
);
|
|
116
|
+
// Never trust a declaration enough to allocate it up front. A forged
|
|
117
|
+
// 511 MiB size must not force 511 MiB of memory for a tiny member.
|
|
118
|
+
let output: Uint8Array = new Uint8Array(0);
|
|
119
|
+
let stream: ZipChunkStream;
|
|
120
|
+
try {
|
|
121
|
+
stream = (entry as unknown as StreamableZipObject).internalStream('uint8array');
|
|
122
|
+
} catch (error: unknown) {
|
|
123
|
+
const safeError = asZipSafetyError(error, path);
|
|
124
|
+
this.failArchive(safeError);
|
|
125
|
+
return Promise.reject(safeError);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
let written = 0;
|
|
131
|
+
let crc = 0xffffffff;
|
|
132
|
+
|
|
133
|
+
const controller: ActiveZipRead = {
|
|
134
|
+
stop: (error) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
this.activeReads.delete(controller);
|
|
138
|
+
// Public JSZip cancellation is cooperative. It prevents another
|
|
139
|
+
// compressed-input tick; data already emitted synchronously by the
|
|
140
|
+
// current tick is ignored because `settled` is now true.
|
|
141
|
+
try {
|
|
142
|
+
stream.pause();
|
|
143
|
+
} catch {
|
|
144
|
+
// Reject with the original safety failure even if a malformed
|
|
145
|
+
// third-party stream cannot be paused cleanly.
|
|
146
|
+
}
|
|
147
|
+
reject(error);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
this.activeReads.add(controller);
|
|
151
|
+
|
|
152
|
+
stream
|
|
153
|
+
.on('data', (chunk: Uint8Array) => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
const nextWritten = written + chunk.byteLength;
|
|
156
|
+
try {
|
|
157
|
+
this.assertChunkAllowed(metadata, nextWritten, chunk.byteLength, effectiveEntryLimit);
|
|
158
|
+
output = growOutput(output, nextWritten, declaredSize, effectiveEntryLimit);
|
|
159
|
+
output.set(chunk, written);
|
|
160
|
+
written = nextWritten;
|
|
161
|
+
crc = updateCrc32(crc, chunk);
|
|
162
|
+
} catch (error: unknown) {
|
|
163
|
+
this.failArchive(asZipSafetyError(error, path));
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.on('error', (error: unknown) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
const code: ZipSafetyErrorCode = message.includes('uncompressed data size mismatch')
|
|
170
|
+
? 'size-mismatch'
|
|
171
|
+
: 'decompression-failed';
|
|
172
|
+
this.failArchive(
|
|
173
|
+
new ZipSafetyError(code, `ZIP import: could not decompress ${path}: ${message}`, {
|
|
174
|
+
path,
|
|
175
|
+
cause: error,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
})
|
|
179
|
+
.on('end', () => {
|
|
180
|
+
if (settled) return;
|
|
181
|
+
if (declaredSize !== undefined && written !== declaredSize) {
|
|
182
|
+
this.failArchive(
|
|
183
|
+
new ZipSafetyError(
|
|
184
|
+
'size-mismatch',
|
|
185
|
+
`ZIP import: archive member size does not match its metadata: ${path}.`,
|
|
186
|
+
{ path, actual: written },
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const actualCrc = (crc ^ 0xffffffff) >>> 0;
|
|
192
|
+
if (expectedCrc !== undefined && actualCrc !== expectedCrc >>> 0) {
|
|
193
|
+
this.failArchive(
|
|
194
|
+
new ZipSafetyError('crc-mismatch', `ZIP import: CRC32 mismatch for ${path}.`, {
|
|
195
|
+
path,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
settled = true;
|
|
202
|
+
this.activeReads.delete(controller);
|
|
203
|
+
resolve(output.byteLength === written ? output : output.slice(0, written));
|
|
204
|
+
})
|
|
205
|
+
.resume();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private assertChunkAllowed(
|
|
210
|
+
entry: ValidatedZipEntry,
|
|
211
|
+
nextEntryBytes: number,
|
|
212
|
+
chunkBytes: number,
|
|
213
|
+
effectiveEntryLimit: number,
|
|
214
|
+
): void {
|
|
215
|
+
if (entry.declaredSize !== undefined && nextEntryBytes > entry.declaredSize) {
|
|
216
|
+
throw new ZipSafetyError(
|
|
217
|
+
'size-mismatch',
|
|
218
|
+
`ZIP import: archive member size does not match its metadata: ${entry.path}.`,
|
|
219
|
+
{ path: entry.path, actual: nextEntryBytes },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
effectiveEntryLimit < this.limits.maxUncompressedBytes &&
|
|
224
|
+
nextEntryBytes > effectiveEntryLimit
|
|
225
|
+
) {
|
|
226
|
+
throw entryTooLargeError(entry.path, nextEntryBytes, effectiveEntryLimit);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const nextTotal = this.emittedBytes + chunkBytes;
|
|
230
|
+
if (nextTotal > this.limits.maxUncompressedBytes) {
|
|
231
|
+
throw archiveTooLargeError(nextTotal, this.limits.maxUncompressedBytes);
|
|
232
|
+
}
|
|
233
|
+
if (nextEntryBytes > effectiveEntryLimit) {
|
|
234
|
+
throw entryTooLargeError(entry.path, nextEntryBytes, effectiveEntryLimit);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ratio =
|
|
238
|
+
entry.compressedSize === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: compressionRatio(nextEntryBytes, entry.compressedSize);
|
|
241
|
+
if (ratio !== undefined && ratio > this.limits.maxCompressionRatio) {
|
|
242
|
+
throw compressionRatioError(entry.path, ratio, this.limits.maxCompressionRatio);
|
|
243
|
+
}
|
|
244
|
+
this.emittedBytes = nextTotal;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private failArchive(error: ZipSafetyError): void {
|
|
248
|
+
if (this.terminalError) return;
|
|
249
|
+
this.terminalError = error;
|
|
250
|
+
for (const active of [...this.activeReads]) active.stop(error);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private throwIfFailed(): void {
|
|
254
|
+
if (this.terminalError) throw this.terminalError;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Load with JSZip, validate metadata, and create a shared bounded read session. */
|
|
259
|
+
export async function openBoundedZipArchive(
|
|
260
|
+
data: ZipInput,
|
|
261
|
+
limits: ZipSafetyLimits = {},
|
|
262
|
+
): Promise<BoundedZipArchive> {
|
|
263
|
+
const resolved = resolveZipSafetyLimits(limits);
|
|
264
|
+
let prepared: { input: ZipInput; bytes?: Uint8Array };
|
|
265
|
+
try {
|
|
266
|
+
prepared = await prepareZipInput(data);
|
|
267
|
+
} catch (error: unknown) {
|
|
268
|
+
throw invalidArchiveError(error);
|
|
269
|
+
}
|
|
270
|
+
if (prepared.bytes) {
|
|
271
|
+
const declaredEntries = declaredZipEntryCount(prepared.bytes);
|
|
272
|
+
if (declaredEntries !== undefined && declaredEntries > resolved.maxEntries) {
|
|
273
|
+
throw tooManyEntriesError(declaredEntries, resolved.maxEntries);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let zip: JSZip;
|
|
277
|
+
try {
|
|
278
|
+
zip = await JSZip.loadAsync(prepared.input);
|
|
279
|
+
} catch (error: unknown) {
|
|
280
|
+
throw invalidArchiveError(error);
|
|
281
|
+
}
|
|
282
|
+
return createBoundedZipArchive(zip, resolved);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function invalidArchiveError(cause: unknown): ZipSafetyError {
|
|
286
|
+
return new ZipSafetyError('invalid-archive', 'ZIP import: archive is not readable.', { cause });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Wrap an existing JSZip instance in the same validation and shared budget. */
|
|
290
|
+
export function createBoundedZipArchive(
|
|
291
|
+
zip: JSZip,
|
|
292
|
+
limits: ZipSafetyLimits = {},
|
|
293
|
+
): BoundedZipArchive {
|
|
294
|
+
const resolved = resolveZipSafetyLimits(limits);
|
|
295
|
+
const entries = validateZipArchive(zip, resolved);
|
|
296
|
+
return new JsZipBoundedArchive(zip, entries, resolved);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function asZipSafetyError(error: unknown, path: string): ZipSafetyError {
|
|
300
|
+
if (error instanceof ZipSafetyError) return error;
|
|
301
|
+
return new ZipSafetyError(
|
|
302
|
+
'decompression-failed',
|
|
303
|
+
`ZIP import: could not decompress ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
304
|
+
{ path, cause: error },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function growOutput(
|
|
309
|
+
current: Uint8Array,
|
|
310
|
+
required: number,
|
|
311
|
+
declaredSize: number | undefined,
|
|
312
|
+
limit: number,
|
|
313
|
+
): Uint8Array {
|
|
314
|
+
if (required <= current.byteLength) return current;
|
|
315
|
+
const maximum = Math.min(declaredSize ?? limit, limit);
|
|
316
|
+
const grownSize = current.byteLength === 0 ? required : current.byteLength * 2;
|
|
317
|
+
const nextSize = Math.min(maximum, Math.max(required, grownSize));
|
|
318
|
+
const next = new Uint8Array(nextSize);
|
|
319
|
+
next.set(current);
|
|
320
|
+
return next;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let crcTable: Uint32Array | undefined;
|
|
324
|
+
|
|
325
|
+
function updateCrc32(crc: number, bytes: Uint8Array): number {
|
|
326
|
+
const table = crcTable ?? (crcTable = makeCrcTable());
|
|
327
|
+
let next = crc >>> 0;
|
|
328
|
+
for (const byte of bytes) next = table[(next ^ byte) & 0xff]! ^ (next >>> 8);
|
|
329
|
+
return next >>> 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function makeCrcTable(): Uint32Array {
|
|
333
|
+
const table = new Uint32Array(256);
|
|
334
|
+
for (let n = 0; n < table.length; n++) {
|
|
335
|
+
let value = n;
|
|
336
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
337
|
+
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
338
|
+
}
|
|
339
|
+
table[n] = value >>> 0;
|
|
340
|
+
}
|
|
341
|
+
return table;
|
|
342
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown fidelity reporting for exporters that intentionally support
|
|
3
|
+
* only a subset of the core Markdown AST. Keeping the capability profiles in
|
|
4
|
+
* one exhaustive table prevents each exporter from silently drifting as the
|
|
5
|
+
* core AST grows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
MarkdownBlockNode,
|
|
10
|
+
MarkdownDocument,
|
|
11
|
+
MarkdownInlineNode,
|
|
12
|
+
} from '@bendyline/squisq/markdown';
|
|
13
|
+
|
|
14
|
+
type ContentNodeType = MarkdownBlockNode['type'] | MarkdownInlineNode['type'];
|
|
15
|
+
export type MarkdownExportProfile = 'docx' | 'pdf' | 'pptx' | 'epub';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exhaustive by construction: adding a Markdown node type in core makes this
|
|
19
|
+
* package fail typechecking until its export fidelity is considered here.
|
|
20
|
+
*/
|
|
21
|
+
const CONTENT_NODE_TYPES: Record<ContentNodeType, true> = {
|
|
22
|
+
heading: true,
|
|
23
|
+
paragraph: true,
|
|
24
|
+
blockquote: true,
|
|
25
|
+
list: true,
|
|
26
|
+
code: true,
|
|
27
|
+
thematicBreak: true,
|
|
28
|
+
table: true,
|
|
29
|
+
htmlBlock: true,
|
|
30
|
+
math: true,
|
|
31
|
+
definition: true,
|
|
32
|
+
footnoteDefinition: true,
|
|
33
|
+
containerDirective: true,
|
|
34
|
+
leafDirective: true,
|
|
35
|
+
definitionList: true,
|
|
36
|
+
text: true,
|
|
37
|
+
emphasis: true,
|
|
38
|
+
strong: true,
|
|
39
|
+
delete: true,
|
|
40
|
+
inlineCode: true,
|
|
41
|
+
link: true,
|
|
42
|
+
image: true,
|
|
43
|
+
break: true,
|
|
44
|
+
htmlInline: true,
|
|
45
|
+
inlineMath: true,
|
|
46
|
+
footnoteReference: true,
|
|
47
|
+
linkReference: true,
|
|
48
|
+
imageReference: true,
|
|
49
|
+
textDirective: true,
|
|
50
|
+
mention: true,
|
|
51
|
+
inlineIcon: true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const COMMON_SUPPORTED: readonly ContentNodeType[] = [
|
|
55
|
+
'heading',
|
|
56
|
+
'paragraph',
|
|
57
|
+
'blockquote',
|
|
58
|
+
'list',
|
|
59
|
+
'code',
|
|
60
|
+
'thematicBreak',
|
|
61
|
+
'table',
|
|
62
|
+
'htmlBlock',
|
|
63
|
+
'math',
|
|
64
|
+
'text',
|
|
65
|
+
'emphasis',
|
|
66
|
+
'strong',
|
|
67
|
+
'delete',
|
|
68
|
+
'inlineCode',
|
|
69
|
+
'link',
|
|
70
|
+
'image',
|
|
71
|
+
'break',
|
|
72
|
+
'htmlInline',
|
|
73
|
+
'inlineMath',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const PROFILE_SUPPORT: Record<MarkdownExportProfile, ReadonlySet<ContentNodeType>> = {
|
|
77
|
+
docx: new Set([...COMMON_SUPPORTED, 'footnoteDefinition', 'footnoteReference']),
|
|
78
|
+
pdf: new Set([...COMMON_SUPPORTED, 'footnoteDefinition', 'footnoteReference']),
|
|
79
|
+
pptx: new Set(COMMON_SUPPORTED),
|
|
80
|
+
epub: new Set(COMMON_SUPPORTED),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const PROFILE_LABELS: Record<MarkdownExportProfile, string> = {
|
|
84
|
+
docx: 'DOCX',
|
|
85
|
+
pdf: 'PDF',
|
|
86
|
+
pptx: 'PPTX',
|
|
87
|
+
epub: 'EPUB',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
interface AstLikeNode {
|
|
91
|
+
type?: string;
|
|
92
|
+
children?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Return a user-facing warning when an exporter will omit Markdown nodes. */
|
|
96
|
+
export function markdownFidelityWarnings(
|
|
97
|
+
document: MarkdownDocument,
|
|
98
|
+
profile: MarkdownExportProfile,
|
|
99
|
+
): string[] {
|
|
100
|
+
const supported = PROFILE_SUPPORT[profile];
|
|
101
|
+
const omitted = new Map<ContentNodeType, number>();
|
|
102
|
+
|
|
103
|
+
const visit = (candidate: unknown): void => {
|
|
104
|
+
if (!candidate || typeof candidate !== 'object') return;
|
|
105
|
+
const node = candidate as AstLikeNode;
|
|
106
|
+
const type = node.type as ContentNodeType | undefined;
|
|
107
|
+
|
|
108
|
+
if (type && Object.prototype.hasOwnProperty.call(CONTENT_NODE_TYPES, type)) {
|
|
109
|
+
if (!supported.has(type)) {
|
|
110
|
+
// The exporter drops this whole subtree, so report the highest-fidelity
|
|
111
|
+
// boundary once instead of inflating the count with its descendants.
|
|
112
|
+
omitted.set(type, (omitted.get(type) ?? 0) + 1);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(node.children)) {
|
|
118
|
+
for (const child of node.children) visit(child);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const child of document.children) visit(child);
|
|
123
|
+
if (omitted.size === 0) return [];
|
|
124
|
+
|
|
125
|
+
const total = [...omitted.values()].reduce((sum, count) => sum + count, 0);
|
|
126
|
+
const details = [...omitted].map(([type, count]) => `${type} (${count})`).join(', ');
|
|
127
|
+
return [
|
|
128
|
+
`${PROFILE_LABELS[profile]} export omitted ${total} unsupported Markdown node(s): ${details}.`,
|
|
129
|
+
];
|
|
130
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Narrow EOCD preflight for rejecting record-count bombs before JSZip allocation. */
|
|
2
|
+
|
|
3
|
+
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
|
|
4
|
+
const ZIP64_END_OF_CENTRAL_DIRECTORY = 0x06064b50;
|
|
5
|
+
const ZIP64_END_LOCATOR = 0x07064b50;
|
|
6
|
+
const UINT16_MAX = 0xffff;
|
|
7
|
+
const MIN_EOCD_BYTES = 22;
|
|
8
|
+
const MAX_ZIP_COMMENT_BYTES = 0xffff;
|
|
9
|
+
|
|
10
|
+
export type ZipInput = ArrayBuffer | Uint8Array | Blob;
|
|
11
|
+
|
|
12
|
+
/** Materialize Blob input once when possible so preflight bytes feed JSZip too. */
|
|
13
|
+
export async function prepareZipInput(
|
|
14
|
+
data: ZipInput,
|
|
15
|
+
): Promise<{ input: ZipInput; bytes?: Uint8Array }> {
|
|
16
|
+
if (ArrayBuffer.isView(data)) {
|
|
17
|
+
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
18
|
+
return { input: bytes, bytes };
|
|
19
|
+
}
|
|
20
|
+
if (!isBlobLike(data)) {
|
|
21
|
+
const bytes = new Uint8Array(data);
|
|
22
|
+
return { input: bytes, bytes };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const bytes = await readBlobBytes(data);
|
|
26
|
+
return bytes ? { input: bytes, bytes } : { input: data };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the declared total central-directory record count when unambiguous.
|
|
31
|
+
* Returns undefined for absent, trailing, malformed, or unsupported metadata;
|
|
32
|
+
* JSZip remains authoritative in those cases.
|
|
33
|
+
*/
|
|
34
|
+
export function declaredZipEntryCount(bytes: Uint8Array): number | undefined {
|
|
35
|
+
const eocdOffset = findEocd(bytes);
|
|
36
|
+
if (eocdOffset === undefined) return undefined;
|
|
37
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
38
|
+
const count = view.getUint16(eocdOffset + 10, true);
|
|
39
|
+
if (count !== UINT16_MAX) return count;
|
|
40
|
+
return readZip64EntryCount(view, eocdOffset);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findEocd(bytes: Uint8Array): number | undefined {
|
|
44
|
+
if (bytes.byteLength < MIN_EOCD_BYTES) return undefined;
|
|
45
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
46
|
+
const earliest = Math.max(0, bytes.byteLength - MIN_EOCD_BYTES - MAX_ZIP_COMMENT_BYTES);
|
|
47
|
+
for (let offset = bytes.byteLength - MIN_EOCD_BYTES; offset >= earliest; offset--) {
|
|
48
|
+
if (view.getUint32(offset, true) !== END_OF_CENTRAL_DIRECTORY) continue;
|
|
49
|
+
const commentLength = view.getUint16(offset + 20, true);
|
|
50
|
+
if (offset + MIN_EOCD_BYTES + commentLength === bytes.byteLength) return offset;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readZip64EntryCount(view: DataView, eocdOffset: number): number | undefined {
|
|
56
|
+
const locatorOffset = eocdOffset - 20;
|
|
57
|
+
if (locatorOffset < 0 || view.getUint32(locatorOffset, true) !== ZIP64_END_LOCATOR) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const recordOffset = readSafeUint64(view, locatorOffset + 8);
|
|
61
|
+
if (
|
|
62
|
+
recordOffset === undefined ||
|
|
63
|
+
recordOffset < 0 ||
|
|
64
|
+
recordOffset + 40 > view.byteLength ||
|
|
65
|
+
view.getUint32(recordOffset, true) !== ZIP64_END_OF_CENTRAL_DIRECTORY
|
|
66
|
+
) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return readSafeUint64(view, recordOffset + 32);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSafeUint64(view: DataView, offset: number): number | undefined {
|
|
73
|
+
if (offset < 0 || offset + 8 > view.byteLength) return undefined;
|
|
74
|
+
const low = view.getUint32(offset, true);
|
|
75
|
+
const high = view.getUint32(offset + 4, true);
|
|
76
|
+
const value = high * 0x1_0000_0000 + low;
|
|
77
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isBlobLike(data: ZipInput): data is Blob {
|
|
81
|
+
const candidate = data as Blob;
|
|
82
|
+
return typeof candidate.size === 'number' && typeof candidate.slice === 'function';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readBlobBytes(blob: Blob): Promise<Uint8Array | undefined> {
|
|
86
|
+
const arrayBuffer = (blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer;
|
|
87
|
+
if (typeof arrayBuffer === 'function') {
|
|
88
|
+
return new Uint8Array(await arrayBuffer.call(blob));
|
|
89
|
+
}
|
|
90
|
+
if (typeof FileReader === 'undefined') return undefined;
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const reader = new FileReader();
|
|
93
|
+
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
|
94
|
+
reader.onerror = () => reject(reader.error ?? new Error('Could not read ZIP blob.'));
|
|
95
|
+
reader.readAsArrayBuffer(blob);
|
|
96
|
+
});
|
|
97
|
+
}
|