diff options
Diffstat (limited to 'public/bluebey-studio/src/zip.js')
| -rw-r--r-- | public/bluebey-studio/src/zip.js | 218 |
1 files changed, 218 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/zip.js b/public/bluebey-studio/src/zip.js new file mode 100644 index 0000000..21a1955 --- /dev/null +++ b/public/bluebey-studio/src/zip.js @@ -0,0 +1,218 @@ +/** + * 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' }); +} |
