Up to 50 images at once; single-image flow unchanged. Includes a dependency-free stored-ZIP writer. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
91 lines
2.7 KiB
JavaScript
91 lines
2.7 KiB
JavaScript
/**
|
|
* Minimal ZIP writer (stored, no compression). Images are already compressed,
|
|
* so deflating them gains nothing, and this avoids a dependency.
|
|
*/
|
|
|
|
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;
|
|
})();
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function header(size, write) {
|
|
const view = new DataView(new ArrayBuffer(size));
|
|
write(view);
|
|
return new Uint8Array(view.buffer);
|
|
}
|
|
|
|
/** @param {{name: string, data: Uint8Array}[]} files @returns {Blob} */
|
|
export function createZip(files) {
|
|
const encoder = new TextEncoder();
|
|
const now = new Date();
|
|
const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
|
const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
|
|
|
const parts = [];
|
|
const central = [];
|
|
let offset = 0;
|
|
|
|
for (const { name, data } of files) {
|
|
const nameBytes = encoder.encode(name);
|
|
const crc = crc32(data);
|
|
|
|
const local = header(30, (v) => {
|
|
v.setUint32(0, 0x04034b50, true);
|
|
v.setUint16(4, 20, true);
|
|
v.setUint16(6, 0x0800, true); // UTF-8 names
|
|
v.setUint16(8, 0, true); // stored
|
|
v.setUint16(10, time, true);
|
|
v.setUint16(12, date, true);
|
|
v.setUint32(14, crc, true);
|
|
v.setUint32(18, data.length, true);
|
|
v.setUint32(22, data.length, true);
|
|
v.setUint16(26, nameBytes.length, true);
|
|
});
|
|
parts.push(local, nameBytes, data);
|
|
|
|
central.push(
|
|
header(46, (v) => {
|
|
v.setUint32(0, 0x02014b50, true);
|
|
v.setUint16(4, 20, true);
|
|
v.setUint16(6, 20, true);
|
|
v.setUint16(8, 0x0800, true);
|
|
v.setUint16(10, 0, true);
|
|
v.setUint16(12, time, true);
|
|
v.setUint16(14, date, true);
|
|
v.setUint32(16, crc, true);
|
|
v.setUint32(20, data.length, true);
|
|
v.setUint32(24, data.length, true);
|
|
v.setUint16(28, nameBytes.length, true);
|
|
v.setUint32(42, offset, true);
|
|
}),
|
|
nameBytes
|
|
);
|
|
|
|
offset += local.length + nameBytes.length + data.length;
|
|
}
|
|
|
|
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
|
const end = header(22, (v) => {
|
|
v.setUint32(0, 0x06054b50, true);
|
|
v.setUint16(8, files.length, true);
|
|
v.setUint16(10, files.length, true);
|
|
v.setUint32(12, centralSize, true);
|
|
v.setUint32(16, offset, true);
|
|
});
|
|
|
|
return new Blob([...parts, ...central, end], { type: "application/zip" });
|
|
}
|