/** * A minimal ZIP writer — stored (uncompressed) entries only. * * Exporting a notebook means handing over many files at once, and the browser * can only offer one download. That needs a container, and ZIP is the one every * operating system opens without being asked twice. * * Written out rather than pulled in: the format's stored variant is a handful of * fixed-layout records, which is less code than the dependency would be, and one * fewer thing to keep updated in a project maintained by one person. Compression * is skipped on purpose — note text is small, and images are already compressed. */ const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[i] = c >>> 0; } return t; })(); function crc32(bytes: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!)! & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } /** ZIP stores timestamps as MS-DOS date and time words. */ function dosDateTime(d: Date): { date: number; time: number } { return { date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), }; } export interface ZipEntry { /** Path inside the archive; forward slashes make folders. */ name: string; data: Uint8Array | string; } const encoder = new TextEncoder(); const toBytes = (d: Uint8Array | string) => (typeof d === "string" ? encoder.encode(d) : d); /** * The archive as bytes. * * Kept separate from `createZip` so the format can be verified without a Blob — * jsdom's has no `arrayBuffer`, and a pure byte function is the easier thing to * reason about in any case. */ export function createZipBytes(entries: ZipEntry[], now = new Date()): Uint8Array { const { date, time } = dosDateTime(now); const chunks: Uint8Array[] = []; const central: Uint8Array[] = []; let offset = 0; for (const entry of entries) { const name = encoder.encode(entry.name); const data = toBytes(entry.data); const crc = crc32(data); const local = new DataView(new ArrayBuffer(30)); local.setUint32(0, 0x04034b50, true); // local file header local.setUint16(4, 20, true); // version needed local.setUint16(6, 0x0800, true); // UTF-8 names local.setUint16(8, 0, true); // stored, no compression local.setUint16(10, time, true); local.setUint16(12, date, true); local.setUint32(14, crc, true); local.setUint32(18, data.length, true); local.setUint32(22, data.length, true); local.setUint16(26, name.length, true); local.setUint16(28, 0, true); // extra field length chunks.push(new Uint8Array(local.buffer), name, data); const dir = new DataView(new ArrayBuffer(46)); dir.setUint32(0, 0x02014b50, true); // central directory header dir.setUint16(4, 20, true); // version made by dir.setUint16(6, 20, true); // version needed dir.setUint16(8, 0x0800, true); dir.setUint16(10, 0, true); dir.setUint16(12, time, true); dir.setUint16(14, date, true); dir.setUint32(16, crc, true); dir.setUint32(20, data.length, true); dir.setUint32(24, data.length, true); dir.setUint16(28, name.length, true); dir.setUint32(42, offset, true); // where the local header sits central.push(new Uint8Array(dir.buffer), name); offset += 30 + name.length + data.length; } const centralSize = central.reduce((n, c) => n + c.length, 0); const end = new DataView(new ArrayBuffer(22)); end.setUint32(0, 0x06054b50, true); // end of central directory end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true); end.setUint32(12, centralSize, true); end.setUint32(16, offset, true); const parts = [...chunks, ...central, new Uint8Array(end.buffer)]; const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let at = 0; for (const part of parts) { out.set(part, at); at += part.length; } return out; } /** * Wraps bytes in a Blob. * * Goes through the underlying buffer rather than handing the view straight to * `new Blob`, which TypeScript rejects because a view may sit on a shared * buffer. The offsets are honoured, so a view over part of a larger buffer * yields only its own bytes. */ export function bytesToBlob(bytes: Uint8Array, type: string): Blob { // Copied into a plain ArrayBuffer: a view can sit on a SharedArrayBuffer, // which Blob does not accept, and the copy also honours the view's offsets. const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); return new Blob([copy.buffer], { type }); } export function createZip(entries: ZipEntry[], now = new Date()): Blob { return bytesToBlob(createZipBytes(entries, now), "application/zip"); } /** Hands a blob to the browser as a download. */ export function download(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); // Freed on the next tick; revoking immediately cancels the download in Safari. setTimeout(() => URL.revokeObjectURL(url), 10_000); } export const __testing = { crc32, dosDateTime };