/** * Store-only ZIP writer. * * The studio exports a whole batch of images at once (one PNG per comic panel * or sticker), and a single download is far nicer to handle than a dozen files, * so the batch is packed into one archive here. * * PNG payloads are already DEFLATE-compressed, so running deflate over them a * second time costs time and saves nothing measurable: every entry is stored * verbatim (compression method 0, `compressedSize === uncompressedSize`) and no * data descriptor is needed. * * The module has no imports, never touches the DOM beyond `Blob`, and stamps * entries with a fixed DOS date unless one is passed in, so the same input * always produces a byte-for-byte identical archive. That keeps the output safe * to cache, diff, hash and test. * * Archive layout (every integer little-endian, no extra fields, no comments, no * directory entries): * * [local file header + stored data] one per file * [central directory entry] one per file * [end of central directory record] */ /** Signature of a local file header ("PK\x03\x04"). */ const LOCAL_SIGNATURE = 0x04034b50; /** Signature of a central directory entry ("PK\x01\x02"). */ const CENTRAL_SIGNATURE = 0x02014b50; /** Signature of the end of central directory record ("PK\x05\x06"). */ const EOCD_SIGNATURE = 0x06054b50; /** ZIP 2.0 is the oldest version that covers everything this writer emits. */ const VERSION_NEEDED = 20; /** General purpose bit 11: the entry name is UTF-8, not CP437. */ const FLAG_UTF8 = 0x0800; /** Compression method 0: stored. */ const METHOD_STORE = 0; /** Largest value a 32-bit ZIP field can hold. Nothing may reach 4 GiB. */ const MAX_FIELD = 0xffffffff; /** Largest value a 16-bit ZIP field can hold (entry count, name length). */ const MAX_SHORT = 0xffff; const LOCAL_HEADER_SIZE = 30; const CENTRAL_HEADER_SIZE = 46; const EOCD_SIZE = 22; /** 1980-01-01 00:00 in DOS form: year offset 0, month 1, day 1, midnight. */ const DEFAULT_TIME = 0; const DEFAULT_DATE = (1 << 5) | 1; /** * CRC-32 (polynomial 0xEDB88320, reflected), the checksum every ZIP entry must * carry. The table is built once at module load; eight table-driven bits per * byte is fast enough that a several-megabyte PNG batch stays imperceptible. */ const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[i] = c >>> 0; } return table; })(); /** * @param {Uint8Array} bytes * @returns {number} unsigned 32-bit CRC-32 */ export function crc32(bytes) { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } /** Clip a Date into the DOS date/time pair stored in the headers. */ function toDosDateTime(date) { const year = date.getFullYear(); // The DOS epoch starts in 1980; anything older is clamped to the epoch. if (year < 1980) return { time: DEFAULT_TIME, date: DEFAULT_DATE }; const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1); const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); return { time: time & MAX_SHORT, date: day & MAX_SHORT }; } /** * Entry names live inside the archive, where the separator is always "/": a * backslash (what Windows paths use) would be taken as part of the name, and a * leading slash would look like an absolute path to some extractors. */ function normalizeName(name) { return String(name ?? '') .replace(/\\/g, '/') .replace(/^\/+/, ''); } /** Accept any byte source, plus plain strings meaning UTF-8 text. */ function toBytes(data, encoder) { if (typeof data === 'string') return encoder.encode(data); if (data instanceof Uint8Array) return data; if (ArrayBuffer.isView(data)) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } if (data instanceof ArrayBuffer) return new Uint8Array(data); throw new TypeError('ZIP: file data must be a Uint8Array, an ArrayBuffer or a string'); } function writeU16(view, offset, value) { view.setUint16(offset, value & MAX_SHORT, true); } function writeU32(view, offset, value) { view.setUint32(offset, value >>> 0, true); } /** * Pack `files` into a stored (uncompressed) ZIP archive. * * @param {Array<{name: string, data: Uint8Array | string}>} files * a string `data` is encoded as UTF-8 text * @param {object} [options] * @param {Date} [options.date] * timestamp for every entry; defaults to 1980-01-01 00:00 so that the same * input always yields the same bytes * @returns {Blob} an `application/zip` blob */ export function createZip(files, { date } = {}) { const encoder = new TextEncoder(); const stamp = date ? toDosDateTime(date) : { time: DEFAULT_TIME, date: DEFAULT_DATE }; // First pass: normalise the input and measure it, so the output buffer can be // allocated exactly once instead of being grown and copied. const entries = []; let localTotal = 0; let centralTotal = 0; for (const file of files ?? []) { const nameBytes = encoder.encode(normalizeName(file.name)); const data = toBytes(file.data, encoder); if (data.length > MAX_FIELD) { throw new Error('ZIP: a file is 4 GiB or larger; zip64 is not supported'); } if (nameBytes.length > MAX_SHORT) { throw new Error('ZIP: a file name is longer than 65535 bytes'); } const length = LOCAL_HEADER_SIZE + nameBytes.length + data.length; localTotal += length; centralTotal += CENTRAL_HEADER_SIZE + nameBytes.length; entries.push({ nameBytes, data, crc: crc32(data), length, offset: 0 }); } if (entries.length > MAX_SHORT) { throw new Error('ZIP: more than 65535 files; zip64 is not supported'); } if (localTotal + centralTotal + EOCD_SIZE > MAX_FIELD) { throw new Error('ZIP: the archive is 4 GiB or larger; zip64 is not supported'); } const bytes = new Uint8Array(localTotal + centralTotal + EOCD_SIZE); const view = new DataView(bytes.buffer); let offset = 0; for (const entry of entries) { entry.offset = offset; writeU32(view, offset + 0, LOCAL_SIGNATURE); writeU16(view, offset + 4, VERSION_NEEDED); writeU16(view, offset + 6, FLAG_UTF8); writeU16(view, offset + 8, METHOD_STORE); writeU16(view, offset + 10, stamp.time); writeU16(view, offset + 12, stamp.date); writeU32(view, offset + 14, entry.crc); writeU32(view, offset + 18, entry.data.length); writeU32(view, offset + 22, entry.data.length); writeU16(view, offset + 26, entry.nameBytes.length); writeU16(view, offset + 28, 0); // extra field length bytes.set(entry.nameBytes, offset + LOCAL_HEADER_SIZE); bytes.set(entry.data, offset + LOCAL_HEADER_SIZE + entry.nameBytes.length); offset += entry.length; } const centralOffset = offset; for (const entry of entries) { writeU32(view, offset + 0, CENTRAL_SIGNATURE); writeU16(view, offset + 4, VERSION_NEEDED); // version made by (host 0 = MS-DOS) writeU16(view, offset + 6, VERSION_NEEDED); writeU16(view, offset + 8, FLAG_UTF8); writeU16(view, offset + 10, METHOD_STORE); writeU16(view, offset + 12, stamp.time); writeU16(view, offset + 14, stamp.date); writeU32(view, offset + 16, entry.crc); writeU32(view, offset + 20, entry.data.length); writeU32(view, offset + 24, entry.data.length); writeU16(view, offset + 28, entry.nameBytes.length); writeU16(view, offset + 30, 0); // extra field length writeU16(view, offset + 32, 0); // file comment length writeU16(view, offset + 34, 0); // disk number start writeU16(view, offset + 36, 0); // internal attributes writeU32(view, offset + 38, 0); // external attributes writeU32(view, offset + 42, entry.offset); bytes.set(entry.nameBytes, offset + CENTRAL_HEADER_SIZE); offset += CENTRAL_HEADER_SIZE + entry.nameBytes.length; } writeU32(view, offset + 0, EOCD_SIGNATURE); writeU16(view, offset + 4, 0); // number of this disk writeU16(view, offset + 6, 0); // disk holding the central directory writeU16(view, offset + 8, entries.length); writeU16(view, offset + 10, entries.length); writeU32(view, offset + 12, offset - centralOffset); writeU32(view, offset + 16, centralOffset); writeU16(view, offset + 20, 0); // archive comment length return new Blob([bytes], { type: 'application/zip' }); }