// Lectura/escritura mínima de CBZ (ZIP) sin dependencias. // Escribe sin compresión (las imágenes ya vienen comprimidas); lee entradas "stored" y "deflate". import fs from 'node:fs' import zlib from 'node:zlib' const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i // entries: [{ name, data: Buffer }]. Escribe a un temporal y renombra para no dejar CBZ a medias. export function writeCbz(file, entries) { const parts = [] const central = [] let offset = 0 for (const { name, data } of entries) { const nameBuf = Buffer.from(name, 'utf8') const crc = zlib.crc32(data) const local = Buffer.alloc(30) local.writeUInt32LE(0x04034b50, 0) local.writeUInt16LE(20, 4) // versión necesaria local.writeUInt16LE(0x0800, 6) // nombres en UTF-8 local.writeUInt16LE(0, 8) // sin compresión local.writeUInt16LE(0, 10) // hora DOS local.writeUInt16LE(0x21, 12) // fecha DOS (1980-01-01) local.writeUInt32LE(crc, 14) local.writeUInt32LE(data.length, 18) local.writeUInt32LE(data.length, 22) local.writeUInt16LE(nameBuf.length, 26) local.writeUInt16LE(0, 28) parts.push(local, nameBuf, data) const cd = Buffer.alloc(46) cd.writeUInt32LE(0x02014b50, 0) cd.writeUInt16LE(20, 4) cd.writeUInt16LE(20, 6) cd.writeUInt16LE(0x0800, 8) cd.writeUInt16LE(0, 10) cd.writeUInt16LE(0, 12) cd.writeUInt16LE(0x21, 14) cd.writeUInt32LE(crc, 16) cd.writeUInt32LE(data.length, 20) cd.writeUInt32LE(data.length, 24) cd.writeUInt16LE(nameBuf.length, 28) cd.writeUInt32LE(offset, 42) central.push(cd, nameBuf) offset += 30 + nameBuf.length + data.length } const cdSize = central.reduce((n, b) => n + b.length, 0) const end = Buffer.alloc(22) end.writeUInt32LE(0x06054b50, 0) end.writeUInt16LE(entries.length, 8) end.writeUInt16LE(entries.length, 10) end.writeUInt32LE(cdSize, 12) end.writeUInt32LE(offset, 16) const tmp = `${file}.part` fs.writeFileSync(tmp, Buffer.concat([...parts, ...central, end])) fs.renameSync(tmp, file) } // Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }] export function readCbzIndex(file) { return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX')) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) } // Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB). export function readZipIndex(file) { const fd = fs.openSync(file, 'r') try { const { size } = fs.fstatSync(fd) const tailLen = Math.min(size, 65557) const tail = Buffer.alloc(tailLen) fs.readSync(fd, tail, 0, tailLen, size - tailLen) const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])) if (eocd < 0) throw new Error('ZIP inválido') const count = tail.readUInt16LE(eocd + 10) const cdSize = tail.readUInt32LE(eocd + 12) const cdOffset = tail.readUInt32LE(eocd + 16) if (cdSize > size) throw new Error('ZIP inválido') const cd = Buffer.alloc(cdSize) fs.readSync(fd, cd, 0, cdSize, cdOffset) const entries = [] const lh = Buffer.alloc(30) for (let p = 0, i = 0; i < count; i++) { const method = cd.readUInt16LE(p + 10) const compSize = cd.readUInt32LE(p + 20) const unzipped = cd.readUInt32LE(p + 24) const nameLen = cd.readUInt16LE(p + 28) const extraLen = cd.readUInt16LE(p + 30) const commentLen = cd.readUInt16LE(p + 32) const localOffset = cd.readUInt32LE(p + 42) if (compSize > size) throw new Error('ZIP inválido') const name = cd.toString('utf8', p + 46, p + 46 + nameLen) p += 46 + nameLen + extraLen + commentLen fs.readSync(fd, lh, 0, 30, localOffset) const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28) entries.push({ name, method, start, size: compSize, unzipped }) } return entries } finally { fs.closeSync(fd) } } export function imageType(name) { const ext = name.split('.').pop().toLowerCase() return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream' } // Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más. export function readCbzEntry(file, entry, maxBytes) { if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`) const fd = fs.openSync(file, 'r') const buf = Buffer.alloc(entry.size) try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) } return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf } // Envía una entrada del CBZ por la respuesta HTTP. export function sendCbzEntry(res, file, entry) { const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' } if (entry.method === 0) { res.writeHead(200, { ...headers, 'Content-Length': entry.size }) fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res) return } const data = readCbzEntry(file, entry) res.writeHead(200, headers) res.end(data) }