Libros de texto: fuente Project Gutenberg y lectura continua

Los EPUB se convierten en el servidor en una lista cerrada de bloques y el
navegador los maqueta en páginas según su pantalla. Cada página se dibuja en
un canvas y llega al lector como una imagen más, así que hoja, sombra,
hendidura y sonido no cambian.

El libro se lee de corrido: cada capítulo empieza debajo del anterior y el
progreso se sigue guardando por capítulo. El catálogo sale del OPDS de
gutenberg.org, con reintentos.

Ajustes: mostrar solo manga, solo libros o ambos, por usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:02:29 -06:00
co-authored by Claude Opus 5.5
parent 5776389ebb
commit be19e35156
6 changed files with 1089 additions and 66 deletions
+14 -8
View File
@@ -56,8 +56,14 @@ export function writeCbz(file, entries) {
fs.renameSync(tmp, file)
}
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }]
// 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)
@@ -65,7 +71,7 @@ export function readCbzIndex(file) {
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('CBZ inválido')
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)
@@ -76,18 +82,18 @@ export function readCbzIndex(file) {
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)
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
p += 46 + nameLen + extraLen + commentLen
if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue
fs.readSync(fd, lh, 0, 30, localOffset)
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
entries.push({ name, method, start, size: compSize })
entries.push({ name, method, start, size: compSize, unzipped })
}
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
return entries
} finally {
fs.closeSync(fd)
}
@@ -98,13 +104,13 @@ export function imageType(name) {
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.
export function readCbzEntry(file, entry) {
// 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) : buf
return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
}
// Envía una entrada del CBZ por la respuesta HTTP.