Files

130 lines
5.2 KiB
JavaScript

// 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)
}