aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/zip.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/zip.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/zip.js')
-rw-r--r--bluebey-studio/src/zip.js218
1 files changed, 218 insertions, 0 deletions
diff --git a/bluebey-studio/src/zip.js b/bluebey-studio/src/zip.js
new file mode 100644
index 0000000..21a1955
--- /dev/null
+++ b/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' });
+}