// A store-only ZIP writer (spec 002 ยง2): covers are JPEG/PNG/WebP and don't shrink, so no compression. export interface ZipEntry { name: string; bytes: Uint8Array; modified: Date; } export class ZipError extends Error { override name = "ZipError"; } const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; } return table; })(); export 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; } /** MS-DOS time and date, from the UTC fields (ZIP has no time zone). */ function dosTime(d: Date): { time: number; date: number } { return { time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1), date: ((Math.max(d.getUTCFullYear(), 1980) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(), }; } const UTF8_NAMES = 0x0800; const LIMIT = 0xffffffff; export function zipStore(entries: readonly ZipEntry[]): Uint8Array { const names = entries.map((e) => new TextEncoder().encode(e.name)); const local = entries.reduce((sum, e, i) => sum + 30 + names[i]!.length + e.bytes.length, 0); const central = names.reduce((sum, n) => sum + 46 + n.length, 0); if (local + central + 22 > LIMIT) throw new ZipError("These covers add up to more than 4 GiB; select fewer."); const out = new Uint8Array(local + central + 22); const view = new DataView(out.buffer); const offsets: number[] = []; const crcs: number[] = []; let at = 0; entries.forEach((e, i) => { const name = names[i]!; const { time, date } = dosTime(e.modified); const crc = crc32(e.bytes); offsets.push(at); crcs.push(crc); view.setUint32(at, 0x04034b50, true); view.setUint16(at + 4, 20, true); view.setUint16(at + 6, UTF8_NAMES, true); view.setUint16(at + 8, 0, true); view.setUint16(at + 10, time, true); view.setUint16(at + 12, date, true); view.setUint32(at + 14, crc, true); view.setUint32(at + 18, e.bytes.length, true); view.setUint32(at + 22, e.bytes.length, true); view.setUint16(at + 26, name.length, true); view.setUint16(at + 28, 0, true); out.set(name, at + 30); out.set(e.bytes, at + 30 + name.length); at += 30 + name.length + e.bytes.length; }); const centralStart = at; entries.forEach((e, i) => { const name = names[i]!; const { time, date } = dosTime(e.modified); view.setUint32(at, 0x02014b50, true); view.setUint16(at + 4, 20, true); view.setUint16(at + 6, 20, true); view.setUint16(at + 8, UTF8_NAMES, true); view.setUint16(at + 10, 0, true); view.setUint16(at + 12, time, true); view.setUint16(at + 14, date, true); view.setUint32(at + 16, crcs[i]!, true); view.setUint32(at + 20, e.bytes.length, true); view.setUint32(at + 24, e.bytes.length, true); view.setUint16(at + 28, name.length, true); // extra, comment, disk number, internal attributes: 0 view.setUint32(at + 38, 0, true); view.setUint32(at + 42, offsets[i]!, true); out.set(name, at + 46); at += 46 + name.length; }); view.setUint32(at, 0x06054b50, true); view.setUint16(at + 8, entries.length, true); view.setUint16(at + 10, entries.length, true); view.setUint32(at + 12, at - centralStart, true); view.setUint32(at + 16, centralStart, true); return out; }