aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/zip.js
blob: 21a1955210bc610399f15e923347697901485c24 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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' });
}