Files
manga-reader/app/cbz.js
T
ninoandClaude Opus 5.5 bb1f3ff8e4 Libros: Wikisource, EPUB propios y lectura sin conexión
Wikisource en español como segunda fuente, con el EPUB que genera ws-export
y portadas generadas con el título.

Cada usuario puede subir sus EPUB, visibles solo para él en cualquier ruta.
Los archivos manipulados (ZIP bombs, cabeceras falsas) se rechazan.

Un libro se puede guardar entero en el dispositivo para leerlo sin conexión.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:00:44 -06:00

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