diff --git a/.gitignore b/.gitignore
index ff4375d..6e81f30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,14 @@ data/*
# Descargas cuando se usan dentro del proyecto (MANGA_DIR_HOST por defecto)
mangas/*
!mangas/.keep
+# Configuración real de cada servidor (la plantilla sí se versiona)
.env
.env.*
!.env.example
node_modules/
+# Documentos internos de traspaso (se guardan solo en el servidor)
+HANDOFF-LIBRO3D.md
+# Basura del sistema y registros
+*.log
+.DS_Store
+Thumbs.db
diff --git a/app/credits.js b/app/credits.js
new file mode 100644
index 0000000..60fc640
--- /dev/null
+++ b/app/credits.js
@@ -0,0 +1,92 @@
+// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
+// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
+// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
+const SIDE = 16
+const SIG_BYTES = SIDE * SIDE
+const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
+const SAME_SHAPE = 0.03
+const MIN_PAGES = 5
+const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
+const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
+
+const decode = s => Buffer.from(String(s || ''), 'base64')
+const spread = b => {
+ const mean = b.reduce((a, x) => a + x, 0) / b.length
+ return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
+}
+const difference = (a, b) => {
+ let sum = 0
+ for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
+ return sum / SIG_BYTES / 255
+}
+
+/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
+export function repeatedPages(rows, ids) {
+ const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
+ .filter(p => p.sig.length === SIG_BYTES)
+ const out = {}
+ for (const r of rows) {
+ if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
+ const hidden = []
+ for (const p of all) {
+ if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
+ if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
+ if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
+ }
+ if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
+ }
+ return out
+}
+
+export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS page_sigs (
+ manga_id TEXT NOT NULL,
+ chapter_id TEXT NOT NULL,
+ n INTEGER NOT NULL,
+ pages TEXT NOT NULL,
+ PRIMARY KEY (manga_id, chapter_id)
+ );
+ CREATE TABLE IF NOT EXISTS hide_credits (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ manga_id TEXT NOT NULL,
+ PRIMARY KEY (user_id, manga_id)
+ );`)
+ const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
+
+ function validPages(pages, n) {
+ const entries = Object.entries(pages || {})
+ if (!entries.length || entries.length > 3) return false
+ return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
+ }
+
+ async function route(p, method, req, res, user) {
+ let m
+ if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
+ const { on } = await readBody(req)
+ if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
+ else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
+ json(res, 200, { hideCredits: !!on })
+ return true
+ }
+ if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
+ const body = await readBody(req)
+ const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
+ if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
+ const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
+ for (const c of wanted) {
+ const pages = body.sigs?.[c.id]
+ if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
+ }
+ const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
+ const known = new Map(rows.map(r => [r.id, r.n]))
+ // Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
+ const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
+ const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
+ json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
+ return true
+ }
+ return false
+ }
+ return { route, enabled }
+}
diff --git a/app/meta.js b/app/meta.js
index 355827b..c5e7234 100644
--- a/app/meta.js
+++ b/app/meta.js
@@ -12,6 +12,7 @@
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
+import { parseMuVolumes, workTitle } from './volumes.js'
const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
@@ -119,6 +120,31 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}, wait)
}
+ // Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida).
+ function mdMatches(mangaId) {
+ return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => {
+ const { manga } = await fetchMangaFull(mangaId, 'es')
+ const title = workTitle(manga.title)
+ const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)
+ return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id)
+ }, true)
+ }
+
+ // Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos.
+ async function muVolumes(mangaId) {
+ const l = await links(mangaId, true)
+ if (!l.mu) return {}
+ return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => {
+ const rows = []
+ for (let page = 1; page <= 20; page++) {
+ const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page })
+ rows.push(...(r?.results || []).map(x => x.record))
+ if (!r || page * 100 >= r.total_hits) break
+ }
+ return parseMuVolumes(rows)
+ }, true)
+ }
+
// ---------- Ficha ----------
const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status
startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult
@@ -287,5 +313,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
return true
}
- return { route, links, info, cachedInfo, dates }
+ return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
}
diff --git a/app/public/app.js b/app/public/app.js
index 17496bb..44e5cad 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -681,7 +681,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '
Cargando…
'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
- const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
+ const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -732,6 +732,7 @@ async function viewManga(id, langOverride) {
Opciones ${icon('x')}
+
${offline ? '' : ``}
${clipCount ? `
${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''} ` : ''}
${menuItems}
@@ -1172,6 +1173,23 @@ async function viewManga(id, langOverride) {
b.hidden = offline || isBook
}
renderFollow()
+ // Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre.
+ let hideCredits = !!creditsOn
+ function renderCredits() {
+ const b = $('#hidecred')
+ if (!b) return
+ b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos`
+ b.classList.toggle('on', hideCredits)
+ b.setAttribute('aria-pressed', hideCredits)
+ b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.'
+ b.hidden = offline || !vols.length
+ }
+ $('#hidecred').addEventListener('click', async () => {
+ const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } })
+ hideCredits = r.hideCredits
+ renderCredits()
+ toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas')
+ })
$('#follow').addEventListener('click', async () => {
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
isFollowing = r.following
@@ -1251,13 +1269,59 @@ async function viewManga(id, langOverride) {
const d = new Date(e.t).toISOString().slice(0, 10)
return `
${e.approx ? '≈ ' : ''}${fmtDate(d)} `
}
+ // Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella.
+ let vols = [], volOf = new Map(), folded = new Set()
+ if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => {
+ if (!r.volumes?.length || !ul?.isConnected) return
+ vols = r.volumes
+ volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v])))
+ const open = volOf.get(progress?.chapter_id) || vols[0]
+ folded = new Set(vols.filter(v => v !== open).map(v => v.vol))
+ renderList()
+ renderCredits()
+ }, () => {})
+ const volHead = v => {
+ if (!v) return '
Sin tomo
'
+ const inVol = chapters.filter(c => volOf.get(c.id) === v)
+ const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 }
+ const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id)
+ return `
+ ${v.thumb ? ` ` : ''}Tomo ${esc(v.vol)} ${plural(inVol.length, 'capítulo')}${read ? ` · ${read} leídos` : ''}
+ ${icon('book')} Leer tomo `
+ }
+ ul?.addEventListener('click', e => {
+ const b = e.target.closest('button.voltoggle')
+ if (!b) return
+ folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol)
+ renderList()
+ })
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
const picking = sel?.mode === 'pick'
- const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
+ pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
- ul.innerHTML = list.map((c, i) => {
+ // Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera.
+ const seen = new Set()
+ const grouped = !vols.length ? list : list.flatMap(c => {
+ const v = volOf.get(c.id)
+ if (!v) return [c]
+ if (seen.has(v)) return []
+ seen.add(v)
+ return list.filter(x => volOf.get(x.id) === v)
+ })
+ let curVol
+ ul.innerHTML = grouped.map((c, i) => {
+ const v = volOf.get(c.id) || null
+ const head = vols.length && v !== curVol ? volHead(v) : ''
+ curVol = v
+ if (v && !picking && folded.has(v.vol)) return head
+ return head + chapterItem(c, i)
+ }).join('')
+ }
+ let pickable = new Set()
+ function chapterItem(c, i) {
+ const picking = sel?.mode === 'pick'
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
if (picking) {
@@ -1268,7 +1332,6 @@ async function viewManga(id, langOverride) {
}
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
return `
${esc(chapLabel(c))} ${chapMeta(c, r, done)} `
- }).join('')
}
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
@@ -1295,6 +1358,7 @@ async function viewManga(id, langOverride) {
}
// ---------- Lector ----------
+const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// ---------- Sonido al pasar página ----------
@@ -1804,6 +1868,130 @@ async function createBook(text, mangaId, size) {
return book
}
+// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción.
+async function pageSig(url) {
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Sin imagen')
+ const bmp = await createImageBitmap(await res.blob())
+ const ar = bmp.width / bmp.height
+ // A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome.
+ let from = bmp, w = bmp.width, h = bmp.height, cv, cx
+ while (w > 64 || h > 64 || w !== h) {
+ w = Math.max(64, w >> 1)
+ h = Math.max(64, h >> 1)
+ cv = document.createElement('canvas')
+ cv.width = w
+ cv.height = h
+ cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 })
+ cx.imageSmoothingQuality = 'high'
+ cx.drawImage(from, 0, 0, w, h)
+ from = cv
+ }
+ bmp.close()
+ const d = cx.getImageData(0, 0, 64, 64).data
+ const out = new Uint8Array(256)
+ for (let y = 0; y < 16; y++) {
+ for (let x = 0; x < 16; x++) {
+ let sum = 0
+ for (let j = 0; j < 4; j++) {
+ for (let i = 0; i < 4; i++) {
+ const k = ((y * 4 + j) * 64 + x * 4 + i) * 4
+ sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2]
+ }
+ }
+ out[y * 16 + x] = Math.round(sum / 16)
+ }
+ }
+ return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 }
+}
+
+// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra.
+async function creditPages(mangaId, parts) {
+ const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length }))
+ if (!chapters.length) return {}
+ const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } })
+ const first = await ask()
+ if (!first.missing.length) return first.hidden
+ const jobs = parts.filter(x => first.missing.includes(x.id))
+ const sigs = {}
+ let next = 0
+ await Promise.all(Array.from({ length: 3 }, async () => {
+ while (next < jobs.length) {
+ const x = jobs[next++], n = x.urls.length
+ try {
+ const pages = {}
+ for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i])
+ sigs[x.id] = pages
+ } catch {} // se volverá a intentar la próxima vez
+ }
+ }))
+ return (await ask(sigs)).hidden
+}
+
+// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo.
+async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) {
+ const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))])
+ const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes
+ const vols = r?.volumes || []
+ const at = vols.findIndex(v => v.chapters.includes(chapterId))
+ if (at < 0) return null
+ const v = vols[at]
+ $('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`)
+ const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}`
+ const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id))
+ const parts = new Array(want.length)
+ let nextJob = 0
+ await Promise.all(Array.from({ length: 4 }, async () => {
+ while (nextJob < want.length) {
+ const k = nextJob++, { c, i } = want[k]
+ const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null)
+ if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages }
+ }
+ }))
+ const ok = parts.filter(Boolean)
+ if (!ok.some(x => x.id === chapterId)) return null
+ const cover = v.front ? await within(new Promise(res => {
+ const im = new Image()
+ im.onload = () => res(im.naturalWidth > 0)
+ im.onerror = () => res(false)
+ im.src = v.front
+ }), 15000) : false
+ // Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima.
+ const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {}
+ const pages = cover ? [v.front] : []
+ const starts = new Set()
+ for (const x of ok) {
+ const out = new Set(hidden[x.id] || [])
+ x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j))
+ x.start = pages.length
+ x.count = x.keep.length
+ starts.add(x.start)
+ pages.push(...x.keep.map(j => x.urls[j]))
+ }
+ const partOf = c => ok.find(x => x.i === c)
+ const tome = {
+ vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts,
+ has: c => !!partOf(c),
+ chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i },
+ chapterStart: c => partOf(c).start,
+ chapterPages: c => partOf(c).count,
+ // El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última
+ // visible es acabar el capítulo.
+ rawCount: c => partOf(c).urls.length,
+ rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] },
+ localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at },
+ // Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo.
+ async refresh(c) {
+ for (const x of ok) {
+ if (x.local || x.i < c || x.i > c + 1) continue
+ const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null)
+ if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]]
+ }
+ },
+ }
+ return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null }
+}
+
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '
'
@@ -1823,9 +2011,9 @@ async function viewReader(mangaId, chapterId, startPage) {
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId)
- const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
- const prev = idx > 0 ? chapters[idx - 1] : null
- const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
+ let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
+ let prev = idx > 0 ? chapters[idx - 1] : null
+ let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const isPaged = () => settings.mode !== 'vertical'
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
@@ -1844,7 +2032,23 @@ async function viewReader(mangaId, chapterId, startPage) {
if (location.hash !== opening) { book.release(); return }
book.busy = () => turning
}
- const pages = chap.pages
+ // Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen
+ // siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto.
+ let tome = null, nextVol = null
+ if (!isBook && settings.tomes !== false && idx >= 0) {
+ const opening = location.hash
+ const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits)
+ if (location.hash !== opening) return
+ if (loaded) {
+ tome = loaded.tome
+ const first = tome.parts[0].i, last = tome.parts.at(-1).i
+ prev = first > 0 ? chapters[first - 1] : null
+ next = last < chapters.length - 1 ? chapters[last + 1] : null
+ nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo
+ }
+ }
+ const seq = book || tome // las páginas cruzan varios capítulos
+ const pages = tome ? tome.pages : chap.pages
const pageSrc = book ? i => book.src(i) : i => pages[i]
const total = book ? book.count : pages.length
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
@@ -1858,6 +2062,10 @@ async function viewReader(mangaId, chapterId, startPage) {
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
bookResume = null
}
+ if (tome) {
+ const local = tome.localPage(idx, Math.max(wanted, 0))
+ wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local
+ }
let page = Math.min(Math.max(wanted, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
const lastShown = () => (isPaged() ? shown.at(-1) : page)
@@ -1866,12 +2074,13 @@ async function viewReader(mangaId, chapterId, startPage) {
let saveTimer = null
const bookPayload = (c, local, count) => ({
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
- chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
+ chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
})
const payload = () => {
- if (!book) return mangaPayload()
- const p = lastShown(), c = book.chapterAt(p)
- return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
+ if (!seq) return mangaPayload()
+ const p = lastShown(), c = seq.chapterAt(p)
+ const local = Math.max(0, p - seq.chapterStart(c))
+ return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c))
}
const mangaPayload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
@@ -1886,7 +2095,9 @@ async function viewReader(mangaId, chapterId, startPage) {
window.addEventListener('pagehide', beacon)
saveNow()
- let curChap = book ? book.chapterAt(page) : idx
+ let curChap = seq ? seq.chapterAt(page) : idx
+ const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0
+ const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown())
const chapOpts = chapters.map((c, i) => `
${esc(chapLabel(c))} `).join('')
app.innerHTML = `
@@ -1905,6 +2116,8 @@ async function viewReader(mangaId, chapterId, startPage) {
${Object.entries(PAGE_ANIMS).map(([k, v]) => `${v} `).join('')}
${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `${label} `).join('')}
${Object.entries(FILTERS).map(([k, [label]]) => `${label} `).join('')}
+ ${B3D_OK ? '
Libro 3D' : ''}
+ ${isBook ? '' : '
Por tomos'}
☀
${manga.source !== 'md' || chap.local ? '' : '
Ahorro datos'}
${chap.hdAvailable ? '
Versión HD'
@@ -1916,16 +2129,18 @@ async function viewReader(mangaId, chapterId, startPage) {
${OFFLINE_OK && !isBook ? `
${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'} ` : ''}
${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).
+
`
const reader = $('.reader', app)
+ let b3d = null, b3dLoading = false // el libro 3D, si está activo
const box = $('#pages', app)
const slider = $('#slider', app)
$('#mode').value = settings.mode
@@ -1944,14 +2159,15 @@ async function viewReader(mangaId, chapterId, startPage) {
const applyFilter = () => {
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
box.style.setProperty('--page-filter', f || 'none')
+ b3d?.setFilter(f)
reader.classList.toggle('bluelight', settings.filter === 'blue')
}
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = book ? `` : `` : ``
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
@@ -1969,28 +2185,31 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const syncBookChapter = () => {
const p = lastShown()
- book.trim(p)
- if (!isPaged()) {
- box.querySelectorAll('img[src]').forEach(im => {
- if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
- })
+ if (book) {
+ book.trim(p)
+ if (!isPaged()) {
+ box.querySelectorAll('img[src]').forEach(im => {
+ if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
+ })
+ }
}
- const c = book.chapterAt(p)
+ const c = seq.chapterAt(p)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; j++) {
- const count = book.chapterPages(j)
+ const count = tome ? tome.rawCount(j) : seq.chapterPages(j)
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
}
}
curChap = c
+ if (tome) { chapterId = chapters[c].id; chapter = chapters[c] }
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
}
lastBookPage = p
syncBookNav()
}
const setPageLabel = () => {
- if (book) syncBookChapter()
+ if (seq) syncBookChapter()
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
@@ -2015,7 +2234,8 @@ async function viewReader(mangaId, chapterId, startPage) {
// ancha ocupa las dos caras.
function spreadOf(i) {
for (let s = 0; s < total;) {
- const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
+ // En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre.
+ const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
s += g.length
}
@@ -2054,7 +2274,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const spread = spreadOf(i)
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
}
- const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
+ const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) }
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
function isPan(old) {
const imgs = old.querySelectorAll('img')
@@ -2077,7 +2297,7 @@ async function viewReader(mangaId, chapterId, startPage) {
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map((i, n) => ` `).join('')
const imgs = [...stage.querySelectorAll('img')]
- if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
+ if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
@@ -2110,8 +2330,11 @@ async function viewReader(mangaId, chapterId, startPage) {
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
- if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir))
- if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
+ if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir))
+ if (b3dOn()) {
+ box.replaceChildren(stage)
+ syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag)
+ } else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i)
@@ -2372,7 +2595,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
function scheduleLeafTextures(delay = 300) {
clearTimeout(leafTimer)
- if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
+ if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay)
}
function nextLeafTexture() {
if (turning) return scheduleLeafTextures(400)
@@ -2794,7 +3017,7 @@ async function viewReader(mangaId, chapterId, startPage) {
turning = { anims, cleanup }
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
}
- function showEnd() {
+ function showEnd(dir = 0) {
finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
@@ -2802,12 +3025,14 @@ async function viewReader(mangaId, chapterId, startPage) {
atEnd = true
resetZoom()
box.innerHTML = endBlock
- reader.classList.remove('hide-ui')
+ if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima
+ else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir))
+ syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag)
}
function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
- if (n >= total) return showEnd()
+ if (n >= total) return showEnd(dir)
n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
@@ -2816,7 +3041,7 @@ async function viewReader(mangaId, chapterId, startPage) {
shown = g
page = g[0]
setPageLabel()
- if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
+ if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0)
if (changed) scheduleSave()
return
}
@@ -2830,7 +3055,7 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
- if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
+ if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
@@ -2841,6 +3066,67 @@ async function viewReader(mangaId, chapterId, startPage) {
$('.rbar.bottom').classList.toggle('rtl', rtl)
}
+ // --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
+ const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width'
+ const b3dOn = () => b3dWanted() && !atEnd
+ const b3dPaper = () => {
+ const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
+ return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
+ }
+ // El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve.
+ function placeEnd(at, colors) {
+ const el = $('.chapter-end', box)
+ if (!el) return
+ el.classList.toggle('on', !!at)
+ if (!at) return
+ const o = box.getBoundingClientRect()
+ el.style.width = `${at.width}px`
+ el.style.height = `${at.height}px`
+ el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)`
+ if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v)
+ }
+ function syncB3d(dir = 0, drag = false) {
+ const on = b3dWanted(), host = $('#b3d', reader)
+ if (!host) return
+ reader.classList.toggle('b3d', on)
+ host.hidden = !on
+ if (!b3dWanted()) {
+ b3d?.destroy()
+ b3d = null
+ return
+ }
+ // Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces.
+ clearTimeout(leafTimer)
+ for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex)
+ leafTextures.clear()
+ if (b3d) {
+ if (!on) return
+ const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag)
+ if (ctl) turning = ctl
+ return
+ }
+ if (b3dLoading) return
+ b3dLoading = true
+ import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => {
+ b3dLoading = false
+ if (!b3dWanted() || !box.isConnected) return
+ b3d = m.createBook3D(host, {
+ total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i),
+ dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd,
+ })
+ if (!b3d) throw new Error('sin WebGL2')
+ applyFilter()
+ syncB3d()
+ }).catch(err => {
+ console.warn('Libro 3D:', err)
+ b3dLoading = false
+ settings.book3d = false
+ saveGlobal()
+ if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false
+ syncB3d()
+ toast('El libro 3D no está disponible en este dispositivo')
+ })
+ }
function render() {
observer?.disconnect()
const keep = lastShown()
@@ -2849,6 +3135,7 @@ async function viewReader(mangaId, chapterId, startPage) {
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick()
box.dataset.dir = settings.dir
+ syncB3d()
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
syncDirUi()
$('#dir').hidden = !isPaged()
@@ -2898,6 +3185,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
box.classList.toggle('zoomed', zoom.s > 1)
+ if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y)
$('#zoomreset').hidden = zoom.s === 1
}
function clampPan() {
@@ -2930,7 +3218,7 @@ async function viewReader(mangaId, chapterId, startPage) {
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
const xr = cx / window.innerWidth
const center = xr > 0.3 && xr < 0.7
- if (dbl && (center || zoom.s > 1)) {
+ if (dbl && !atEnd && (center || zoom.s > 1)) {
clearTimeout(uiTimer)
if (zoom.s > 1) resetZoom()
else zoomAt(2.5, ...local(cx, cy))
@@ -2943,15 +3231,24 @@ async function viewReader(mangaId, chapterId, startPage) {
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
}
box.addEventListener('pointerdown', e => {
- if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
+ if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return
+ // Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre.
+ if (e.isPrimary && pts.size) {
+ pts.clear()
+ tap = null
+ if (gesture?.type === 'turn') finishTurn()
+ gesture = null
+ }
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
if (e.pointerType === 'mouse' && e.button !== 0) return
if (settings.sfx !== 'off') sfxUnlock()
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) {
- tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
+ tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
if (gesture) box.setPointerCapture?.(e.pointerId)
+ } else if (pts.size === 2 && atEnd) {
+ tap = null // la contraportada no se amplía
} else if (pts.size === 2) {
const [a, b] = [...pts.values()]
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
@@ -2976,14 +3273,14 @@ async function viewReader(mangaId, chapterId, startPage) {
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) {
const fwd = (dx < 0) === (settings.dir === 'ltr')
- if (fwd ? shown.at(-1) + 1 < total : page > 0) {
- const prev = [...shown]
+ if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) {
+ const prev = [...shown], wasEnd = atEnd
finishTurn()
wantDrag = true
grabY = tap.y
try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null }
if (turning?.move) {
- gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
+ gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
turning.move(gesture.d / turning.range)
box.setPointerCapture?.(e.pointerId)
}
@@ -3024,6 +3321,9 @@ async function viewReader(mangaId, chapterId, startPage) {
page = g.prev[0]
setPageLabel()
scheduleSave()
+ if (g.wasEnd) return showEnd() // se quedó en la contraportada
+ atEnd = false
+ if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación
})
return
}
@@ -3031,11 +3331,11 @@ async function viewReader(mangaId, chapterId, startPage) {
const [p] = [...pts.values()]
gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) {
- if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
+ if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY)
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
- if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
gesture = null
tap = null
@@ -3056,7 +3356,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
- const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
+ const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d })
const savePrefs = () => {
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
@@ -3083,26 +3383,42 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
- $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
- $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
+ $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
+ $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
$('#hdone')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
})
+ if ($('#tomefx')) {
+ $('#tomefx').checked = settings.tomes !== false
+ $('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
+ }
+ // Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte.
+ const inSeq = c => !!book || !!tome?.has(c)
$('#chap').addEventListener('change', e => {
- if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
+ const c = chapters.findIndex(x => x.id === e.target.value)
+ if (seq && inSeq(c)) goTo(seq.chapterStart(c))
else location.hash = readUrl(mangaId, e.target.value)
})
- if (book) {
- $('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
+ if (seq) {
+ $('#bnext').addEventListener('click', e => {
+ e.preventDefault()
+ if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1))
+ else location.hash = readUrl(mangaId, chapters[curChap + 1].id)
+ })
$('#bprev').addEventListener('click', e => {
e.preventDefault()
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
let c = curChap
- while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
- goTo(book.chapterStart(c))
+ while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c--
+ if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c))
+ else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0)
+ else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id)
+ else goTo(seq.chapterStart(c))
})
- book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
+ }
+ if (book) {
+ book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) }
}
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
@@ -3127,11 +3443,13 @@ async function viewReader(mangaId, chapterId, startPage) {
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
+ reader.classList.remove('b3dcrop')
$('#clipbtn')?.classList.remove('on')
}
function startCrop() {
if (crop) return endCrop()
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
+ reader.classList.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal
reader.classList.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
@@ -3284,7 +3602,8 @@ async function viewReader(mangaId, chapterId, startPage) {
})
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
- const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
+ const at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter
+ const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), cover: manga.cover || '' })
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
@@ -3305,6 +3624,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
else if (e.key === backKey) backward()
+ else if (atEnd) return
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
else if (e.key === '0') resetZoom()
@@ -3340,9 +3660,22 @@ async function viewReader(mangaId, chapterId, startPage) {
})
}
}
+ if ($('#b3dfx')) {
+ $('#b3dfx').checked = !!settings.book3d
+ $('#b3dfx').addEventListener('change', e => {
+ settings.book3d = e.target.checked
+ saveGlobal()
+ // iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar.
+ if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {})
+ render()
+ })
+ }
render()
cleanup = () => {
+ b3d?.destroy()
+ b3d = null
book?.release()
+ clearInterval(tomeTimer)
freeLeafGL()
endCrop()
if (saveTimer) saveNow()
@@ -3877,6 +4210,24 @@ async function viewStats() {
}
// ---------- Ajustes ----------
+// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura.
+async function tableJpeg(file) {
+ const url = URL.createObjectURL(file)
+ try {
+ const im = new Image()
+ await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url })
+ const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight))
+ const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) })
+ const g = c.getContext('2d')
+ g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa
+ g.fillRect(0, 0, c.width, c.height)
+ g.imageSmoothingQuality = 'high'
+ g.drawImage(im, 0, 0, c.width, c.height)
+ const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88))
+ if (!blob) throw new Error('No se pudo preparar la imagen')
+ return blob
+ } finally { URL.revokeObjectURL(url) }
+}
function viewSettings() {
app.innerHTML = `Ajustes
@@ -3887,6 +4238,13 @@ function viewSettings() {
${icon('book')} Qué mostrar
Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.
${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `${v} `).join('')}
+
`
+ const showTable = () => {
+ const prev = $('#tableprev')
+ if (!prev) return
+ prev.hidden = !me.tableV
+ if (me.tableV) prev.src = `/api/table?v=${me.tableV}`
+ else prev.removeAttribute('src')
+ $('#tabledel').hidden = !me.tableV
+ }
+ showTable()
+ $('#tablefile').addEventListener('change', async e => {
+ const f = e.target.files[0]
+ e.target.value = ''
+ if (!f) return
+ try {
+ const blob = await tableJpeg(f)
+ const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
+ me.tableV = data.tableV
+ showTable()
+ toast('Fondo guardado')
+ } catch (err) { toast(err.message) }
+ })
+ $('#tabledel').addEventListener('click', async () => {
+ try {
+ await api('/api/table', { method: 'DELETE' })
+ me.tableV = 0
+ showTable()
+ toast('La mesa vuelve a ser de madera')
+ } catch (err) { toast(err.message) }
+ })
api('/api/notify').then(n => {
const f = $('#notify')
if (!f) return
diff --git a/app/public/book3d.js b/app/public/book3d.js
new file mode 100644
index 0000000..f7cc9d4
--- /dev/null
+++ b/app/public/book3d.js
@@ -0,0 +1,1065 @@
+// Modo libro 3D: un solo canvas WebGL2 con la mesa, el libro y una cámara de verdad. Hojas y cantos salen de la misma
+// función de altura, así que no pueden separarse. El lector solo le dice qué páginas se ven.
+// Unidades: la página mide 1 de alto. x cruza el libro (la costura en 0), y va hacia arriba de la pantalla, z sale de la mesa.
+
+export const BOOK = { THICK: 0.075, BOARD: 0.008, OVER: 0.004, CROWN: 0.05, FALL: 0.02, DIP: 0.06, SPINE: 0.03 }
+const dipAt = t => Math.exp(-t / BOOK.DIP)
+// Lo que se levanta la hoja sobre un bloque plano. t: 0 en la costura, 1 en el borde exterior. open: 0 cerrado, 1 abierto.
+export const arch = (t, open = 1) => open * (BOOK.CROWN * (1 - dipAt(t)) - BOOK.FALL * t * t)
+// Altura de la hoja a profundidad v (0: la de arriba; 1: la de abajo). La curva se atenúa hasta la mitad en la última.
+// k: 1 con el bloque entero; 0 sin hojas (la mitad sobre la que aún no ha aterrizado la cubierta).
+export function sheetZ(t, v = 0, open = 1, k = 1) {
+ return BOOK.BOARD + k * (BOOK.THICK * (1 - v) * (1 - 0.45 * dipAt(t) * open) + arch(t, open) * (1 - v / 2))
+}
+export const surface = (t, open = 1, k = 1) => sheetZ(t, 0, open, k)
+const LID_Z = BOOK.BOARD * 2 + BOOK.THICK // la cara de arriba de la cubierta con el libro cerrado
+const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
+const easeIO = t => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2)
+// La mitad sobre la que aterriza la cubierta: hasta que está casi abajo no hay nada, solo la mesa. Después la cubierta ya la tapa.
+export const landWidth = a => (a < 0.97 ? 0 : Math.max(0, -Math.cos(a * Math.PI)))
+// Una hoja a media vuelta sobre la costura. a: 0 tumbada en su lado, 1 tumbada en el otro. rigid: la cubierta, que no se dobla.
+// Parte de la hoja en reposo y vuelve a ella: al empezar y al acabar coincide con la superficie del libro, sin saltos.
+export function sheetCurve(a, side, W, rigid = false, cols = 30, ext = 0) {
+ const L = rigid ? 0 : 0.38, pts = []
+ const hinge = rigid ? LID_Z + (surface(0) - LID_Z) * a : surface(0) + 0.0015
+ const rest = t => (rigid ? LID_Z + (surface(t) - LID_Z) * a : surface(t) + 0.0015)
+ let x = 0, z = 0, prev = 0
+ for (let c = 0; c <= cols; c++) {
+ const t = (c / cols) ** 2, phi = Math.PI * (rigid ? a : smooth(0, 1, a * (1 + L) - L * t))
+ x += Math.cos(phi) * (t - prev) * (W + ext)
+ z += Math.sin(phi) * (t - prev) * (W + ext)
+ prev = t
+ pts.push({ t, phi, x: side * x, z: hinge + z + (rest(t) - hinge) * Math.cos(phi) ** 2 })
+ }
+ pts.forEach((p, c) => {
+ const q = pts[Math.min(cols, c + 1)], o = pts[Math.max(0, c - 1)], tx = q.x - o.x, tz = q.z - o.z, l = Math.hypot(tx, tz) || 1
+ p.nx = (side * -tz) / l
+ p.nz = (side * tx) / l
+ })
+ return pts
+}
+// Matriz de color de un filtro CSS (filas: r, g, b, desplazamiento), para aplicarlo a las páginas y no a la mesa.
+export function filterMatrix(css) {
+ let M = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]]
+ const then = N => { M = N.map(r => [0, 1, 2, 3].map(c => r[0] * M[0][c] + r[1] * M[1][c] + r[2] * M[2][c] + (c === 3 ? r[3] : 0))) }
+ for (const [, name, val] of String(css || '').matchAll(/([a-z-]+)\(([-\d.]+)(?:deg|%)?\)/g)) {
+ const v = Number(val), i = 1 - v
+ if (name === 'brightness') then([[v, 0, 0, 0], [0, v, 0, 0], [0, 0, v, 0]])
+ else if (name === 'contrast') { const o = (1 - v) / 2; then([[v, 0, 0, o], [0, v, 0, o], [0, 0, v, o]]) }
+ else if (name === 'invert') { const k = 1 - 2 * v; then([[k, 0, 0, v], [0, k, 0, v], [0, 0, k, v]]) }
+ else if (name === 'grayscale') then([[0.2126 + 0.7874 * i, 0.7152 - 0.7152 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 + 0.2848 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 - 0.7152 * i, 0.0722 + 0.9278 * i, 0]])
+ else if (name === 'sepia') then([[0.393 + 0.607 * i, 0.769 - 0.769 * i, 0.189 - 0.189 * i, 0], [0.349 - 0.349 * i, 0.686 + 0.314 * i, 0.168 - 0.168 * i, 0], [0.272 - 0.272 * i, 0.534 - 0.534 * i, 0.131 + 0.869 * i, 0]])
+ else if (name === 'saturate') then([[0.213 + 0.787 * v, 0.715 - 0.715 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 + 0.285 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 - 0.715 * v, 0.072 + 0.928 * v, 0]])
+ else if (name === 'hue-rotate') {
+ const c = Math.cos((v * Math.PI) / 180), n = Math.sin((v * Math.PI) / 180)
+ then([[0.213 + c * 0.787 - n * 0.213, 0.715 - c * 0.715 - n * 0.715, 0.072 - c * 0.072 + n * 0.928, 0], [0.213 - c * 0.213 + n * 0.143, 0.715 + c * 0.285 + n * 0.14, 0.072 - c * 0.072 - n * 0.283, 0], [0.213 - c * 0.213 - n * 0.787, 0.715 - c * 0.715 + n * 0.715, 0.072 + c * 0.928 + n * 0.072, 0]])
+ }
+ }
+ return M
+}
+
+const norm = v => { const l = Math.hypot(...v) || 1; return v.map(n => n / l) }
+const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
+const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+// Perspectiva cerrada e inclinación mayor que medio campo de visión: así la cámara queda al sur del libro y se ve el canto de abajo.
+export const FOV = (14 * Math.PI) / 180, TILT = (14 * Math.PI) / 180, NEAR = 0.5, FAR = 40
+
+// Matriz de la cámara, por columnas. tilt: inclinación hacia el sur, para ver el libro con algo de ángulo.
+export function cameraMatrix(target, dist, aspect, tilt = TILT, yaw = 0) {
+ const eye = [target[0] + dist * Math.sin(yaw) * Math.cos(tilt), target[1] - dist * Math.sin(tilt), target[2] + dist * Math.cos(yaw) * Math.cos(tilt)]
+ const f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]), s = norm(cross(f, [0, 1, 0])), u = cross(s, f)
+ const k = 1 / Math.tan(FOV / 2), A = (FAR + NEAR) / (NEAR - FAR), B = (2 * FAR * NEAR) / (NEAR - FAR)
+ const row = (v, o) => [v[0], v[1], v[2], o]
+ const V0 = row(s, -dot(s, eye)), V1 = row(u, -dot(u, eye)), V2 = row(f.map(n => -n), dot(f, eye))
+ const m = new Float32Array(16)
+ for (let c = 0; c < 4; c++) {
+ m[c * 4] = (k / aspect) * V0[c]
+ m[c * 4 + 1] = k * V1[c]
+ m[c * 4 + 2] = A * V2[c] + (c === 3 ? B : 0)
+ m[c * 4 + 3] = -V2[c]
+ }
+ return m
+}
+export function project(m, p) {
+ const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
+ return [(m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w]
+}
+// Distancia y objetivo para que los puntos llenen la pantalla, centrados y dejando margen (en fracción de la pantalla).
+export function fitCamera(points, aspect, marginX, marginY) {
+ const target = [0, 0, 0]
+ const n = points.length
+ for (let k = 0; k < 2; k++) target[k] = points.reduce((a, p) => a + p[k], 0) / n
+ const limX = 1 - 2 * marginX, limY = 1 - 2 * marginY
+ let lo = 0.5, hi = 30
+ const extent = d => {
+ for (let pass = 0; pass < 5; pass++) { // recentrar: con perspectiva e inclinación el centro se desplaza
+ const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p))
+ const xs = q.map(a => a[0]), ys = q.map(a => a[1])
+ const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midY = (Math.max(...ys) + Math.min(...ys)) / 2
+ const half = d * Math.tan(FOV / 2)
+ target[0] += midX * half * aspect
+ target[1] += midY * half
+ }
+ const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p))
+ const xs = q.map(a => a[0]), ys = q.map(a => a[1])
+ return Math.max((Math.max(...xs) - Math.min(...xs)) / 2 / limX, (Math.max(...ys) - Math.min(...ys)) / 2 / limY)
+ }
+ for (let i = 0; i < 26; i++) {
+ const d = (lo + hi) / 2
+ if (extent(d) > 1) lo = d; else hi = d
+ }
+ extent(hi)
+ return { target, dist: hi }
+}
+
+// Colores de la portada para la contraportada y sus botones.
+const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
+const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
+const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05])
+const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t)
+const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})`
+// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen.
+export function coverPalette(data) {
+ const boxes = new Map()
+ for (let p = 0; p < data.length; p += 4) {
+ const key = ((data[p] >> 4) << 8) | ((data[p + 1] >> 4) << 4) | (data[p + 2] >> 4)
+ const b = boxes.get(key) || { n: 0, c: [0, 0, 0] }
+ b.n++
+ for (let k = 0; k < 3; k++) b.c[k] += data[p + k] / 255
+ boxes.set(key, b)
+ }
+ const list = [...boxes.values()].map(b => ({ n: b.n, c: b.c.map(v => v / b.n) })).sort((a, b) => b.n - a.n)
+ const base = list[0]?.c || [0.2, 0.14, 0.1]
+ let accent = null, best = 0
+ for (const b of list) {
+ const sat = Math.max(...b.c) - Math.min(...b.c), score = Math.sqrt(b.n) * Math.hypot(b.c[0] - base[0], b.c[1] - base[1], b.c[2] - base[2]) * (0.35 + sat)
+ if (score > best) { best = score; accent = b.c }
+ }
+ accent ||= base
+ // Si apenas se distingue del dominante, se aclara o se oscurece hasta que se vea.
+ const away = luma(base) > 0.3 ? [0, 0, 0] : [1, 1, 1]
+ for (let i = 0; i < 10 && contrast(accent, base) < 2.4; i++) accent = mixColor(accent, away, 0.18)
+ return { base, accent }
+}
+
+// Madera generada: se repite sin que se note el corte.
+function woodCanvas() {
+ const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d')
+ let seed = 7
+ const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
+ for (let p = 0; p < PLANKS; p++) {
+ const x0 = (p * S) / PLANKS, w = S / PLANKS, light = 34 + rnd() * 7
+ g.save()
+ g.beginPath(); g.rect(x0, 0, w, S); g.clip()
+ g.fillStyle = `hsl(${24 + rnd() * 5}, 46%, ${light}%)`
+ g.fillRect(x0, 0, w, S)
+ const phase = rnd() * 6, swing = 4 + rnd() * 10
+ for (let i = 0; i < 170; i++) {
+ const x = x0 + rnd() * w, dark = rnd() < 0.72
+ g.strokeStyle = dark ? `rgba(40,20,8,${0.05 + rnd() * 0.16})` : `rgba(255,214,160,${0.02 + rnd() * 0.07})`
+ g.lineWidth = 0.5 + rnd() * 2.4
+ g.beginPath()
+ for (let y = 0; y <= S; y += 32) {
+ const turn = (y / S) * Math.PI * 2
+ const dx = Math.sin(turn + phase) * swing + Math.sin(turn * 3 + x * 0.05) * 1.6
+ if (y) g.lineTo(x + dx, y); else g.moveTo(x + dx, y)
+ }
+ g.stroke()
+ }
+ g.restore()
+ g.fillStyle = 'rgba(20,9,3,.55)'
+ g.fillRect(x0 - 1, 0, 2, S)
+ }
+ return canvas
+}
+
+const VS = `#version 300 es
+uniform mat4 uM;
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNor;
+layout(location = 2) in vec2 aUV;
+out vec3 vPos; out vec3 vNor; out vec2 vUV;
+void main() { vPos = aPos; vNor = aNor; vUV = aUV; gl_Position = uM * vec4(aPos, 1.0); }`
+const FS = `#version 300 es
+precision highp float;
+uniform int uKind;
+uniform sampler2D uTex;
+uniform vec3 uColor;
+uniform vec4 uMap, uRect;
+uniform float uSoft, uAlpha, uSeamAt, uHas, uFlip, uMirror;
+uniform vec2 uCast;
+uniform vec3 uTab;
+uniform mat3 uFx;
+uniform vec3 uFxO;
+in vec3 vPos; in vec3 vNor; in vec2 vUV;
+out vec4 color;
+const vec3 L = normalize(vec3(-0.16, -0.26, 0.95));
+// Luz: 1 con la hoja plana; se oscurece al inclinarse hacia la costura o en las caras laterales.
+float light() { return clamp(mix(0.55, 1.0, max(dot(normalize(vNor) * uFlip, L), 0.0) / L.z), 0.0, 1.06); }
+// La sombra de la hoja que gira: desde la costura hasta donde cae la proyección de su borde.
+float castShade() {
+ if (uCast.y <= 0.0 || abs(uCast.x) < 0.001) return 1.0;
+ float r = vPos.x / uCast.x;
+ if (r <= 0.0) return 1.0;
+ // La hoja solo mide lo que mide el libro: su sombra no llega a la mesa de más arriba ni de más abajo.
+ float inY = 1.0 - smoothstep(0.5, 0.56, abs(vPos.y));
+ return 1.0 - uCast.y * inY * (1.0 - smoothstep(0.7, 1.15, r));
+}
+vec3 fx(vec3 c) { return clamp(uFx * c + uFxO, 0.0, 1.0); }
+// Misma hendidura que el lector normal, para que la página se vea igual.
+float gutter(float d) {
+ if (d < 0.012) return mix(0.45, 0.62, d / 0.012);
+ if (d < 0.03) return mix(0.62, 0.82, (d - 0.012) / 0.018);
+ if (d < 0.06) return mix(0.82, 0.94, (d - 0.03) / 0.03);
+ if (d < 0.1) return mix(0.94, 1.0, (d - 0.06) / 0.04);
+ return 1.0;
+}
+void main() {
+ if (uKind == 0) { // mesa: la luz cae sobre el libro y se apaga hacia fuera
+ vec3 wood = uTab.z > 0.0 ? texture(uTex, vec2(0.5 + vPos.x * uTab.x, 0.5 - vPos.y * uTab.y), -0.4).rgb : texture(uTex, vUV, -0.4).rgb;
+ float r = length((vPos.xy - uRect.xy) / uRect.zw);
+ color = vec4(wood * mix(1.08, 0.62, smoothstep(0.5, 2.1, r)) * castShade(), 1.0);
+ } else if (uKind == 1) { // sombra en la mesa: un rectángulo difuso
+ vec2 a = smoothstep(uRect.xy - uSoft, uRect.xy + uSoft, vPos.xy) * (1.0 - smoothstep(uRect.zw - uSoft, uRect.zw + uSoft, vPos.xy));
+ color = vec4(0.05, 0.02, 0.0, 1.0) * uAlpha * a.x * a.y;
+ } else if (uKind == 2) { // tapa
+ color = vec4(uColor * light() * castShade(), 1.0);
+ } else if (uKind == 3) { // canto: cada línea es una hoja, y sigue la curva porque la malla la sigue
+ float v = vUV.y * 15.0, w = fwidth(v);
+ float line = 1.0 - smoothstep(0.0, max(w, 0.35), abs(fract(v) - 0.5) - 0.12);
+ vec3 c = uColor * (1.0 - 0.17 * line) * mix(0.82, 1.0, smoothstep(0.0, 0.45, vUV.y));
+ color = vec4(fx(c) * min(1.0, light() * 1.3) * castShade(), 1.0); // el papel de los cantos recibe luz rebotada de la mesa
+ } else if (uKind == 4) { // página
+ float ux = uMirror > 0.5 ? 1.0 - vUV.x : vUV.x;
+ vec2 q = vec2(ux * uMap.x + uMap.y, vUV.y * uMap.z + uMap.w);
+ vec3 c = uColor;
+ if (uHas > 0.5 && q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) c = texture(uTex, q, -0.5).rgb;
+ color = vec4(fx(c) * light() * gutter(uSeamAt < 0.5 ? ux : 1.0 - ux) * castShade(), 1.0);
+ } else if (uKind == 5) { // portada, plana y sin hendidura
+ color = vec4(fx(uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor) * light() * castShade(), 1.0);
+ } else { // lomo: el color del borde de la portada, de arriba abajo
+ color = vec4(fx(uHas > 0.5 ? texture(uTex, vec2(uSeamAt, vUV.y)).rgb : uColor) * light() * castShade(), 1.0);
+ }
+}`
+
+// Junta rejillas en una malla: pos(3) normal(3) uv(2).
+class Shape {
+ constructor() { this.v = []; this.i = [] }
+ grid(cols, rows, at) {
+ const base = this.v.length / 8
+ for (let c = 0; c <= cols; c++) for (let r = 0; r <= rows; r++) this.v.push(...at(c, r))
+ for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
+ const a = base + c * (rows + 1) + r, b = a + rows + 1
+ this.i.push(a, a + 1, b, b, a + 1, b + 1)
+ }
+ return this
+ }
+ box(x0, x1, y0, y1, z0, z1) {
+ const quad = (o, a, b, n) => this.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, c, r])
+ const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0
+ quad([x0, y0, z1], [dx, 0, 0], [0, dy, 0], [0, 0, 1])
+ quad([x0, y0, z0], [dx, 0, 0], [0, 0, dz], [0, -1, 0])
+ quad([x0, y1, z0], [dx, 0, 0], [0, 0, dz], [0, 1, 0])
+ quad([x0, y0, z0], [0, dy, 0], [0, 0, dz], [-1, 0, 0])
+ quad([x1, y0, z0], [0, dy, 0], [0, 0, dz], [1, 0, 0])
+ return this
+ }
+}
+const COLS = 30
+const tAt = c => (c / COLS) ** 2 // más columnas junto a la costura, donde la curva es más cerrada
+function slope(t, open, k) { const e = 0.002, a = Math.min(1, t + e), b = Math.max(0, t - e); return (surface(a, open, k) - surface(b, open, k)) / (a - b) }
+// Una mitad del libro. side: -1 izquierda, 1 derecha. width: lo que ya se ha extendido, de 0 a 1.
+function pageShape(side, W, open, k = 1, width = 1) {
+ return new Shape().grid(COLS, 1, (c, r) => {
+ const t = tAt(c), n = norm([(-side * slope(t, open, k)) / W, 0, 1])
+ return [side * W * width * t, 0.5 - r, surface(t, open, k), ...n, side > 0 ? t : 1 - t, r]
+ })
+}
+// Los cantos de esa mitad, extruidos desde la misma curva: abajo, arriba y por fuera.
+function edgeShape(sh, side, W, open, k = 1, width = 1) {
+ const RINGS = 5, v = r => (r >= RINGS ? 1 : r / RINGS)
+ const z = (t, r) => (r > RINGS ? BOOK.BOARD : sheetZ(t, v(r), open, k))
+ for (const [y, ny] of [[-0.5, -1], [0.5, 1]]) sh.grid(COLS, RINGS + 1, (c, r) => { const t = tAt(c); return [side * W * width * t, y, z(t, r), 0, ny, 0, t, v(r)] })
+ sh.grid(1, RINGS + 1, (c, r) => [side * W * width, c - 0.5, z(1, r), side, 0, 0, c, v(r)])
+ return sh
+}
+function spineShape(outward, top, size) {
+ const STEPS = 10, mid = top / 2
+ return new Shape().grid(STEPS, 1, (c, r) => {
+ const a = (c / STEPS) * Math.PI, sx = Math.sin(a), cz = Math.cos(a)
+ return [outward * size * sx, 0.5 + BOOK.OVER - r * (1 + 2 * BOOK.OVER), mid + mid * cz, outward * sx, 0, cz, c / STEPS, r]
+ })
+}
+// La hoja que gira y, en la cubierta, su grosor por el borde de abajo y por fuera.
+function sheetShape(pts, y0 = -0.5, y1 = 0.5) {
+ const s = new Shape(), n = pts.length - 1
+ s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x, y1 + (y0 - y1) * r, p.z, p.nx, 0, p.nz, p.u, r] })
+ return s
+}
+function rimShape(pts, y0, y1, thick) {
+ const s = new Shape(), n = pts.length - 1, e = pts[n]
+ s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x - p.nx * thick * r, y0, p.z - p.nz * thick * r, 0, -1, 0, 0, 0] })
+ s.grid(1, 1, (c, r) => [e.x - e.nx * thick * r, y0 + (y1 - y0) * c, e.z - e.nz * thick * r, e.nz * Math.sign(e.x || 1), 0, -e.nx * Math.sign(e.x || 1), 0, 0])
+ return s
+}
+
+// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
+// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
+const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
+function createTilt(onChange) {
+ const target = { x: 0, y: 0 }
+ let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
+ const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
+ function screenAngle() {
+ let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
+ const landscape = innerWidth > innerHeight
+ if (landscape && a % 180 === 0) a = 90
+ if (!landscape && a % 180) a = 0
+ return (a * Math.PI) / 180
+ }
+ function add(dx, dy) {
+ const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
+ target.x = clamp(target.x + dx * c - dy * s)
+ target.y = clamp(target.y + dx * s + dy * c)
+ lastFade = lastFade || performance.now()
+ onChange()
+ }
+ const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt }
+ const quiet = v => (Math.abs(v) < QUIET ? 0 : v)
+ function onOrientation(e) {
+ if (e.beta == null || e.gamma == null) return
+ gotOrientation = true
+ const dt = stepOf(e.timeStamp)
+ if (lastOri) {
+ const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g
+ const still = dt > 0 && Math.hypot(dx, dy) / dt < QUIET
+ if (!still && Math.abs(dx) < 25 && Math.abs(dy) < 25) add(dx, dy)
+ }
+ lastOri = { b: e.beta, g: e.gamma }
+ }
+ function onMotion(e) {
+ const r = e.rotationRate
+ if (gotOrientation || r?.alpha == null || r?.beta == null) return
+ const dt = stepOf(e.timeStamp)
+ add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y
+ }
+ function listen(on) {
+ if (on === listening) return
+ listening = on
+ const method = on ? 'addEventListener' : 'removeEventListener'
+ window[method]('devicemotion', onMotion)
+ window[method]('deviceorientation', onOrientation)
+ target.x = target.y = 0
+ lastT = performance.now()
+ lastOri = null
+ onChange()
+ }
+ const forget = () => { if (askOnTouch) document.removeEventListener('click', askOnTouch); askOnTouch = null }
+ // iOS pide permiso desde un toque. Con el permiso ya dado se repite sin toque; si no, espera al siguiente. Sin permiso, el libro queda quieto.
+ function permit() {
+ if (!isSecureContext) return
+ const M = window.DeviceMotionEvent, O = window.DeviceOrientationEvent
+ const asked = M?.requestPermission ? M.requestPermission() : O?.requestPermission ? O.requestPermission() : null
+ if (!asked) return listen(true)
+ asked.then(answer => { forget(); if (answer === 'granted' && document.visibilityState === 'visible') listen(true) }, () => {
+ if (askOnTouch) return
+ askOnTouch = () => { forget(); permit() }
+ document.addEventListener('click', askOnTouch)
+ })
+ }
+ return {
+ target,
+ permit,
+ listen,
+ get listening() { return listening },
+ // Con el dispositivo quieto pueden dejar de llegar lecturas: el regreso al centro va por tiempo.
+ fade(now) {
+ const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S)
+ lastFade = now
+ for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep
+ },
+ stop() { forget(); listen(false) },
+ }
+}
+
+export function createBook3D(container, opts) {
+ const { total, src, whenReady, spreadOf, isWide, still = false } = opts
+ const canvas = document.createElement('canvas')
+ canvas.className = 'b3dcanvas'
+ const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' })
+ if (!gl) return null
+ container.replaceChildren(canvas)
+ let dead = false, raf = 0, u = {}, prog = null, wood = null, tableMesh = null, aniso = null, maxSize = 2400
+ let tableTex = null, tableAspect = 1
+ let backTex = null, backColors = null, backSent = '', backFrom = null, backNear = false, backTimer = 0
+ let W = 0, coverColor = [0.2, 0.14, 0.1], view = null, statics = null, staticKey = '', dyn = null
+ let anim = null, camNow = null, camMove = null, zoom = { s: 1, x: 0, y: 0 }, fxM = filterMatrix('')
+ const textures = new Map(), loading = new Map(), queue = []
+ const fitCache = new Map()
+ let uploadTimer = 0, rect = { width: 1, height: 1 }
+ const tiltNow = { x: 0, y: 0 }
+ const tilt = createTilt(() => draw())
+
+ function makeMesh(sh, usage = gl.STATIC_DRAW) {
+ const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer()
+ gl.bindVertexArray(vao)
+ gl.bindBuffer(gl.ARRAY_BUFFER, vb)
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), usage)
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib)
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), usage)
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0)
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12)
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24)
+ gl.bindVertexArray(null)
+ return { vao, vb, ib, n: sh.i.length }
+ }
+ const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } }
+ const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) }
+ // Mallas que cambian en cada fotograma de una animación: se reutilizan los mismos búferes.
+ function dynMesh(name, sh) {
+ const m = (dyn[name] ||= makeMesh(new Shape(), gl.DYNAMIC_DRAW))
+ gl.bindVertexArray(m.vao)
+ gl.bindBuffer(gl.ARRAY_BUFFER, m.vb)
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.DYNAMIC_DRAW)
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ib)
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.DYNAMIC_DRAW)
+ gl.bindVertexArray(null)
+ m.n = sh.i.length
+ return m
+ }
+
+ function initGL() {
+ const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s }
+ prog = gl.createProgram()
+ gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS))
+ gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS))
+ gl.linkProgram(prog)
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader')
+ gl.useProgram(prog)
+ u = {}
+ for (const n of ['uM', 'uKind', 'uTex', 'uColor', 'uMap', 'uRect', 'uSoft', 'uAlpha', 'uSeamAt', 'uHas', 'uFlip', 'uMirror', 'uCast', 'uTab', 'uFx', 'uFxO']) u[n] = gl.getUniformLocation(prog, n)
+ gl.uniform1i(u.uTex, 0)
+ gl.uniform1f(u.uFlip, 1)
+ gl.enable(gl.DEPTH_TEST)
+ gl.depthFunc(gl.LEQUAL)
+ gl.clearColor(0.29, 0.18, 0.1, 1)
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
+ aniso = gl.getExtension('EXT_texture_filter_anisotropic')
+ maxSize = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), 2400) // más grande no se aprecia y agota la memoria en la tableta
+ wood = gl.createTexture()
+ gl.bindTexture(gl.TEXTURE_2D, wood)
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, woodCanvas())
+ gl.generateMipmap(gl.TEXTURE_2D)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
+ const TABLE = 14, TILE = 1.7
+ tableMesh = makeMesh(new Shape().grid(1, 1, (c, r) => { const x = (c * 2 - 1) * TABLE, y = (r * 2 - 1) * TABLE; return [x, y, 0, 0, 0, 1, x / TILE, y / TILE] }))
+ dyn = {}
+ statics = null
+ staticKey = ''
+ }
+
+ // ----- Texturas -----
+ function makeTexture(source) {
+ const tex = gl.createTexture()
+ gl.bindTexture(gl.TEXTURE_2D, tex)
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
+ gl.generateMipmap(gl.TEXTURE_2D)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
+ return tex
+ }
+ // Fondo elegido por el usuario: una sola imagen fija sobre la mesa, que llena lo que se ve. Si asoma el borde, sigue en espejo, sin corte.
+ function loadTable() {
+ if (!opts.table) return
+ const im = new Image()
+ im.onload = () => {
+ if (dead || gl.isContextLost() || !im.naturalWidth) return
+ tableTex = makeTexture(im)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
+ tableAspect = im.naturalWidth / im.naturalHeight
+ draw()
+ }
+ im.src = opts.table
+ }
+ function tableScale() {
+ let half = 1.5
+ if (W > 0) {
+ const ref = fitOf({ kind: 'open', focus: rect.height > rect.width ? 1 : 0 }) // la vista más abierta de esta pantalla
+ const hy = ref.dist * 1.3 * Math.tan(FOV / 2) * 1.1 + Math.abs(ref.target[1]), hx = hy * (rect.width / rect.height) + Math.abs(ref.target[0])
+ half = Math.max(hy, hx / tableAspect)
+ }
+ return 1 / (2 * half)
+ }
+ // La contraportada: los colores de la portada, en espejo y muy difuminados para que continúe por el lomo, con un marco de su color de acento.
+ function buildBack(im) {
+ try {
+ const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), sg = small.getContext('2d', { willReadFrequently: true })
+ sg.drawImage(im, 0, 0, 48, 72)
+ const { base, accent } = coverPalette(sg.getImageData(0, 0, 48, 72).data)
+ const H = 1152, w = Math.round(H * Math.min(0.85, Math.max(0.45, im.naturalWidth / im.naturalHeight)))
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: H }), g = c.getContext('2d', { willReadFrequently: true })
+ g.fillStyle = css(base)
+ g.fillRect(0, 0, w, H)
+ const blur = Object.assign(document.createElement('canvas'), { width: 3, height: 4 }), bg = blur.getContext('2d')
+ bg.translate(3, 0)
+ bg.scale(-1, 1)
+ bg.drawImage(small, 0, 0, 3, 4)
+ g.imageSmoothingEnabled = true
+ g.imageSmoothingQuality = 'high'
+ g.globalAlpha = 0.4
+ g.drawImage(blur, 0, 0, w, H)
+ g.globalAlpha = 1
+ const shade = g.createRadialGradient(w / 2, H / 2, H * 0.2, w / 2, H / 2, H * 0.72)
+ shade.addColorStop(0, 'rgba(0,0,0,0)')
+ shade.addColorStop(1, 'rgba(0,0,0,.28)')
+ g.fillStyle = shade
+ g.fillRect(0, 0, w, H)
+ const inset = w * 0.055
+ g.strokeStyle = css(accent)
+ g.lineWidth = w * 0.009
+ g.strokeRect(inset, inset, w - 2 * inset, H - 2 * inset)
+ g.lineWidth = w * 0.003
+ g.strokeRect(inset * 1.35, inset * 1.35, w - 2.7 * inset, H - 2.7 * inset)
+ const mid = g.getImageData(Math.round(w * 0.2), Math.round(H * 0.3), Math.round(w * 0.6), Math.round(H * 0.4)).data, surface = [0, 0, 0]
+ for (let p = 0; p < mid.length; p += 4) for (let k = 0; k < 3; k++) surface[k] += mid[p + k]
+ const area = mid.length / 4
+ for (let k = 0; k < 3; k++) surface[k] /= area * 255
+ if (backTex) gl.deleteTexture(backTex)
+ backTex = makeTexture(c)
+ backColors = { surface: css(surface), ink: css(inkOn(surface)), accent: css(accent), accentInk: css(inkOn(accent)), line: css(mixColor(surface, inkOn(surface), 0.45)) }
+ backSent = ''
+ } catch (err) { console.warn('Contraportada:', err) } // sin ella, la tapa queda del color del borde de la portada
+ }
+ // Se fabrica una vez y solo cerca del final: al abrir el capítulo no debe retrasar las primeras páginas.
+ function ensureBack() {
+ if (!backFrom || !backNear || dead || gl.isContextLost()) return
+ const im = backFrom
+ backFrom = null
+ buildBack(im)
+ realBack()
+ draw()
+ }
+ // La contraportada original del tomo, si existe, sustituye a la fabricada; los colores de los botones no cambian.
+ let backImage = null, backAsked = false
+ function realBack() {
+ if (backImage) return placeRealBack()
+ if (!opts.back || backAsked) return
+ backAsked = true
+ const im = new Image()
+ im.onload = () => {
+ if (dead || !im.naturalWidth) return
+ backImage = im
+ placeRealBack()
+ draw()
+ }
+ im.src = opts.back
+ }
+ function placeRealBack() {
+ if (dead || gl.isContextLost() || !backColors) return
+ let source = backImage
+ const big = Math.max(source.naturalWidth, source.naturalHeight)
+ if (big > maxSize) {
+ const k = maxSize / big, c = Object.assign(document.createElement('canvas'), { width: Math.round(source.naturalWidth * k), height: Math.round(source.naturalHeight * k) })
+ c.getContext('2d').drawImage(source, 0, 0, c.width, c.height)
+ source = c
+ }
+ if (backTex) gl.deleteTexture(backTex)
+ backTex = makeTexture(source)
+ }
+ function upload(i, im) {
+ let source = im, w = im.naturalWidth, h = im.naturalHeight
+ if (!w || !h) return
+ if (Math.max(w, h) > maxSize) { // reducir aquí: pasarla entera podría fallar
+ const k = maxSize / Math.max(w, h), c = Object.assign(document.createElement('canvas'), { width: Math.round(w * k), height: Math.round(h * k) }), g = c.getContext('2d')
+ g.imageSmoothingQuality = 'high'
+ g.drawImage(im, 0, 0, c.width, c.height)
+ source = c
+ }
+ if (i === 0) { coverColor = sampleCover(im); backFrom = im; ensureBack() }
+ const tex = makeTexture(source)
+ const had = textures.get(i)
+ if (had) gl.deleteTexture(had.tex)
+ textures.set(i, { tex, w, h })
+ // La forma del libro la da la primera página que no sea ancha.
+ if (!W && w < h) { W = Math.min(0.85, Math.max(0.45, w / h)); staticKey = ''; fitCache.clear() }
+ draw()
+ }
+ function sampleCover(im) {
+ try {
+ const c = Object.assign(document.createElement('canvas'), { width: 8, height: 8 }), g = c.getContext('2d', { willReadFrequently: true })
+ g.drawImage(im, 0, im.naturalHeight * 0.92, im.naturalWidth, im.naturalHeight * 0.08, 0, 0, 8, 8) // el canto de abajo, que es lo que se ve
+ const d = g.getImageData(0, 0, 8, 8).data, sum = [0, 0, 0]
+ for (let p = 0; p < d.length; p += 4) for (let k = 0; k < 3; k++) sum[k] += d[p + k]
+ return sum.map(v => (v / 64 / 255) * 0.62)
+ } catch { return coverColor }
+ }
+ // Se sube de una en una y lo que se ve ahora primero: subir una textura grande para un momento el hilo principal.
+ function pump() {
+ uploadTimer = 0
+ if (dead || !queue.length || gl.isContextLost()) return
+ if (anim && !anim.dragging && anim.p < 1) { uploadTimer = setTimeout(pump, 120); return } // no durante un giro
+ const visible = view ? pagesOf(view) : []
+ const at = queue.findIndex(q => visible.includes(q.i))
+ const [next] = queue.splice(Math.max(0, at), 1)
+ if (!textures.has(next.i) && wanted(next.i)) upload(next.i, next.im)
+ if (queue.length) uploadTimer = setTimeout(pump, visible.includes(queue[0]?.i) ? 0 : 90)
+ }
+ let keepFrom = 0, keepTo = 0
+ let glEpoch = 0 // lo que se pidió antes de perder el contexto ya no vale: su textura nacería muerta y la página quedaría en blanco
+ const wanted = i => i === 0 || (i >= keepFrom && i <= keepTo)
+ function want(i) {
+ if (i == null || i < 0 || i >= total || textures.has(i) || loading.has(i) || gl.isContextLost()) return
+ if (queue.some(q => q.i === i)) return
+ loading.set(i, true)
+ const epoch = glEpoch
+ const go = url => {
+ const im = new Image()
+ im.onload = async () => {
+ await im.decode?.().catch(() => {})
+ if (dead || epoch !== glEpoch) return
+ loading.delete(i)
+ queue.push({ i, im })
+ if (!uploadTimer) uploadTimer = setTimeout(pump, 0)
+ }
+ im.onerror = () => { if (epoch === glEpoch) loading.delete(i) }
+ im.src = url
+ }
+ if (whenReady) whenReady(i, go); else go(src(i))
+ }
+ function trim() {
+ for (const [i, t] of textures) if (!wanted(i)) { gl.deleteTexture(t.tex); textures.delete(i) }
+ for (let n = queue.length - 1; n >= 0; n--) if (!wanted(queue[n].i)) queue.splice(n, 1)
+ }
+
+ // ----- Lo que se ve -----
+ const rtl = () => opts.dir() === 'rtl'
+ function viewOf(group) {
+ const first = group[0], spread = spreadOf(first)
+ if (spread.length === 1 && isWide(first)) return { kind: 'wide', page: first, focus: 0, key: `w${first}` }
+ if (spread.length === 1 && first === 0) return { kind: 'closed', under: rtl() ? -1 : 1, cover: 0, focus: 0, key: 'c' }
+ const [a, b = null] = spread, right = rtl() ? a : b, left = rtl() ? b : a
+ return { kind: 'open', right, left, focus: group.length === 1 && !(spread.length === 1 && opts.double?.()) ? (group[0] === right ? 1 : -1) : 0, key: `o${a}` } // a doble página, una suelta se ve con el libro entero
+ }
+ const pagesOf = v => (v.kind === 'closed' ? [0] : v.kind === 'wide' ? [v.page] : [v.left, v.right]).filter(i => i != null)
+ const pageOn = (v, side) => (v.kind === 'wide' ? { i: v.page, wide: true } : v.kind === 'closed' ? { i: null } : { i: side > 0 ? v.right : v.left })
+ const hasAll = v => pagesOf(v).every(i => textures.has(i))
+
+ // Lo que debe llenar la pantalla. A una página, solo esa mitad; la otra sigue ahí, fuera de cuadro.
+ function frameOf(v) {
+ const O = BOOK.OVER, y0 = -0.5 - O, y1 = 0.5 + O, h = BOOK.BOARD + BOOK.THICK + BOOK.CROWN
+ let x0 = -W - O, x1 = W + O
+ if (v.kind === 'closed') {
+ x0 = v.under > 0 ? -BOOK.SPINE : -W - O
+ x1 = v.under > 0 ? W + O : BOOK.SPINE
+ } else if (v.focus) {
+ x0 = v.focus > 0 ? 0 : -W - O
+ x1 = v.focus > 0 ? W + O : 0
+ }
+ return [[x0, y0, 0], [x1, y0, 0], [x0, y1, 0], [x1, y1, 0], [x0, y0, h], [x1, y0, h], [x0, y1, h], [x1, y1, h]]
+ }
+ // El encuadre se calcula una vez por vista y tamaño de pantalla: no hace falta repetirlo en cada fotograma.
+ function fitOf(v) {
+ const margin = Math.max(14, Math.min(40, Math.min(rect.width, rect.height) * 0.04))
+ const key = `${v.kind}:${v.under || 0}:${v.focus}:${W}:${rect.width}x${rect.height}`
+ let cam = fitCache.get(key)
+ if (!cam) {
+ cam = fitCamera(frameOf(v), rect.width / rect.height, margin / rect.width, margin / rect.height)
+ if (fitCache.size > 12) fitCache.clear()
+ fitCache.set(key, cam)
+ }
+ return cam
+ }
+ const mixCam = (a, b, t) => ({ target: a.target.map((n, k) => n + (b.target[k] - n) * t), dist: a.dist + (b.dist - a.dist) * t })
+
+ // Libro en reposo: la malla se reutiliza. Durante la cubierta se rehace en cada fotograma.
+ function bookShapes(o, under, land) {
+ const O = BOOK.OVER, ed = new Shape()
+ const out = { left: null, right: null }
+ for (const side of [-1, 1]) {
+ const isLand = under && side === -under, k = isLand ? land.k : 1, width = isLand ? land.width : 1
+ if (!width) continue
+ out[side > 0 ? 'right' : 'left'] = pageShape(side, W, o, k, width)
+ edgeShape(ed, side, W, o, k, width)
+ }
+ const landOf = side => (under && side === -under ? land.width : 1)
+ const xL = -W * landOf(-1) - (landOf(-1) ? O : 0), xR = W * landOf(1) + (landOf(1) ? O : 0)
+ out.edges = ed
+ out.board = new Shape().box(xL, xR, -0.5 - O, 0.5 + O, 0, BOOK.BOARD)
+ return out
+ }
+ function staticMeshes(v) {
+ const key = `${v.kind === 'closed' ? `c${v.under}` : 'o'}:${W}`
+ if (key === staticKey && statics) return statics
+ if (statics) for (const m of Object.values(statics)) freeMesh(m)
+ staticKey = key
+ const sh = v.kind === 'closed' ? bookShapes(0, v.under, { k: 0, width: 0 }) : bookShapes(1, 0, null)
+ statics = {}
+ for (const [n, s] of Object.entries(sh)) if (s) statics[n] = makeMesh(s)
+ return statics
+ }
+
+ function pageMap(i, side, wide) {
+ const t = textures.get(i)
+ if (!t) return null
+ const region = wide ? 2 * W : W, a = t.w / t.h
+ let sx = 1, sy = 1 // Fracción del hueco que llena la imagen, sin deformarla
+ if (a < region) sx = a / region; else sy = region / a
+ const ox = (1 - sx) / 2, oy = (1 - sy) / 2
+ const scale = wide ? 0.5 : 1, start = wide && side > 0 ? 0.5 : 0
+ return { tex: t.tex, map: [scale / sx, (start - ox) / sx, 1 / sy, -oy / sy] }
+ }
+ function drawPage(mesh, page, side, mirror = false) {
+ const p = page?.i == null ? null : pageMap(page.i, side, page.wide)
+ gl.uniform1i(u.uKind, 4)
+ gl.uniform1f(u.uSeamAt, side > 0 ? 0 : 1)
+ gl.uniform1f(u.uHas, p ? 1 : 0)
+ gl.uniform1f(u.uMirror, mirror ? 1 : 0)
+ if (p) { gl.bindTexture(gl.TEXTURE_2D, p.tex); gl.uniform4fv(u.uMap, p.map) }
+ gl.uniform3fv(u.uColor, opts.paper())
+ drawMesh(mesh)
+ gl.uniform1f(u.uMirror, 0)
+ }
+ // Dibuja las dos caras de una hoja: la de delante en su lado y la de detrás, ya volteada, en el otro.
+ function drawSheet(mesh, side, front, back, lid = null) {
+ gl.enable(gl.CULL_FACE)
+ gl.frontFace(side > 0 ? gl.CCW : gl.CW)
+ gl.cullFace(gl.BACK)
+ if (lid) {
+ gl.uniform1i(u.uKind, 5)
+ gl.uniform1f(u.uHas, lid.tex ? 1 : 0)
+ if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex)
+ gl.uniform3fv(u.uColor, coverColor)
+ drawMesh(mesh)
+ } else drawPage(mesh, front, side)
+ gl.cullFace(gl.FRONT)
+ gl.uniform1f(u.uFlip, -1)
+ drawPage(mesh, back, -side, true)
+ gl.uniform1f(u.uFlip, 1)
+ gl.disable(gl.CULL_FACE)
+ }
+
+ function drawBook(m, left, right) {
+ gl.uniform1i(u.uKind, 2)
+ gl.uniform3fv(u.uColor, coverColor)
+ drawMesh(m.board)
+ gl.uniform1i(u.uKind, 3)
+ gl.uniform3fv(u.uColor, opts.paper())
+ drawMesh(m.edges)
+ if (m.left) drawPage(m.left, left, -1)
+ if (m.right) drawPage(m.right, right, 1)
+ }
+ // Lo que va en la tapa: la portada delante, o la contraportada por detrás del libro. El lomo toma el color de su borde.
+ const lidOf = isBack => ({ tex: isBack ? backTex : textures.get(0)?.tex })
+ function drawSpine(under, top, size, lid) {
+ gl.uniform1i(u.uKind, 6)
+ gl.uniform1f(u.uHas, lid.tex ? 1 : 0)
+ if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex)
+ gl.uniform3fv(u.uColor, coverColor)
+ gl.uniform1f(u.uSeamAt, under > 0 ? 0.01 : 0.99)
+ drawMesh(dynMesh('spine', spineShape(-under, top, size)))
+ }
+ // La cubierta: una pieza rígida con grosor. a: 0 cerrada, 1 ya abierta.
+ function drawCover(under, a, backPage, lid) {
+ const O = BOOK.OVER
+ const pts = sheetCurve(a, under, W, true, 12, O)
+ pts.forEach(p => { p.u = under > 0 ? p.t : 1 - p.t })
+ const mesh = dynMesh('cover', sheetShape(pts, -0.5 - O, 0.5 + O))
+ gl.uniform1i(u.uKind, 2)
+ gl.uniform3fv(u.uColor, coverColor)
+ drawMesh(dynMesh('rim', rimShape(pts, -0.5 - O, 0.5 + O, BOOK.BOARD)))
+ drawSheet(mesh, under, null, backPage, lid)
+ return pts.at(-1)
+ }
+
+ function stepTilt(now) {
+ tilt.fade(now)
+ const on = !still && zoom.s === 1
+ const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
+ let moving = !!(tilt.target.x || tilt.target.y) && on
+ for (const k of ['x', 'y']) {
+ const d = want[k] - tiltNow[k]
+ if (Math.abs(d) < 0.002) tiltNow[k] = want[k]
+ else { tiltNow[k] += d * 0.25; moving = true }
+ }
+ return moving
+ }
+ function stepAnim(now) {
+ if (!anim) return false
+ const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación
+ anim.last = now
+ if (anim.dragging) {
+ const d = anim.goal - anim.p
+ anim.p += d * 0.35
+ return true
+ }
+ const before = anim.p
+ anim.p = anim.back ? Math.max(0, before - dt / anim.dur) : Math.min(1, before + dt / anim.dur)
+ if ((anim.back && anim.p <= 0) || (!anim.back && anim.p >= 1)) { finishAnim(!anim.back); return !!anim }
+ return true
+ }
+ // Acaba la animación en uno de los dos extremos y deja el libro en reposo.
+ function finishAnim(commit = true) {
+ const a = anim
+ if (!a) return
+ anim = null
+ view = commit ? a.to : a.from
+ camNow = null
+ camMove = null
+ opts.onTurnEnd?.(a.ctl)
+ a.done?.(commit)
+ draw()
+ }
+
+ // Dónde queda la contraportada en la pantalla: el lector pone ahí los botones, y siguen al libro cuando se mueve.
+ function reportBack(M) {
+ if (!opts.onBack) return
+ let at = null
+ if (!anim && view?.back && W > 0) {
+ const O = BOOK.OVER, far = view.under * (W + O)
+ const q = [[0, -0.5 - O], [far, -0.5 - O], [0, 0.5 + O], [far, 0.5 + O]].map(([x, y]) => project(M, [x, y, LID_Z]))
+ const xs = q.map(p => ((p[0] + 1) / 2) * rect.width), ys = q.map(p => ((1 - p[1]) / 2) * rect.height)
+ at = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }
+ }
+ const key = at ? `${Math.round(at.x)},${Math.round(at.y)},${Math.round(at.width)},${Math.round(at.height)}` : 'off'
+ if (key === (backSent || 'off')) return
+ backSent = key
+ opts.onBack(at, backColors)
+ }
+
+ function paint(now) {
+ raf = 0
+ if (dead || gl.isContextLost()) return
+ rect = container.getBoundingClientRect()
+ if (!rect.width || !rect.height) return
+ const dpr = Math.min(window.devicePixelRatio || 1, 3)
+ const cw = Math.round(rect.width * dpr), ch = Math.round(rect.height * dpr)
+ if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch }
+ gl.viewport(0, 0, cw, ch)
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
+ let again = stepTilt(now)
+ if (stepAnim(now)) again = true
+ const v = anim ? anim.to : view
+ const ready = v && W > 0
+ const aspect = cw / ch
+ let cam = { target: [0, 0, 0], dist: 4 }
+ if (ready) {
+ cam = fitOf(v)
+ if (anim) cam = mixCam(anim.camFrom, cam, easeIO(anim.p))
+ else if (camMove) {
+ const t = Math.min(1, (now - camMove.t0) / camMove.dur)
+ cam = mixCam(camMove.from, cam, easeIO(t))
+ if (t >= 1) camMove = null; else again = true
+ }
+ camNow = cam
+ }
+ // La sombra de la hoja se calcula antes, porque cae también en la mesa.
+ let castX = 0, castA = 0, cover = null, sheet = null
+ if (ready && anim) {
+ const e = anim.dragging ? anim.p : easeIO(anim.p)
+ if (anim.kind === 'cover') {
+ cover = { a: anim.opening ? e : 1 - e, under: anim.under }
+ castX = Math.cos(cover.a * Math.PI) * cover.under * (W + BOOK.OVER)
+ castA = Math.sin(cover.a * Math.PI) * 0.5
+ } else {
+ sheet = { a: e, side: anim.side }
+ sheet.pts = sheetCurve(e, anim.side, W)
+ castX = sheet.pts.at(-1).x
+ castA = Math.sin(e * Math.PI) * 0.45
+ }
+ }
+ // Una hoja en alto queda cerca de la cámara: durante el giro, esta se aleja y se pone más vertical para que quepa y no se vea torcida.
+ const lift = cover ? Math.sin(cover.a * Math.PI) : sheet ? 0.35 * Math.sin(sheet.a * Math.PI) : 0
+ if (lift) cam = { target: cam.target, dist: cam.dist * (1 + 0.3 * lift) }
+ // Al rodear el libro, la cámara se aleja un poco para que el lado que se acerca no se salga de la pantalla.
+ const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y))
+ if (round) cam = { target: cam.target, dist: cam.dist * (1 + ORBIT_BACK * round) }
+ const dTilt = ((-tiltNow.x * ORBIT_TILT - 9 * lift) * Math.PI) / 180, yaw = (-tiltNow.y * ORBIT_YAW * Math.PI) / 180
+ const M = cameraMatrix(cam.target, cam.dist, aspect, TILT + dTilt, yaw)
+ // Zoom del lector y el pequeño desplazamiento del movimiento: se aplican a la imagen final, que se vuelve a dibujar nítida.
+ const s = zoom.s, ox = s - 1 + (2 * (zoom.x + tiltNow.y * 3)) / rect.width, oy = -(s - 1) - (2 * (zoom.y + tiltNow.x * 3)) / rect.height
+ for (let c = 0; c < 4; c++) {
+ M[c * 4] = s * M[c * 4] + ox * M[c * 4 + 3]
+ M[c * 4 + 1] = s * M[c * 4 + 1] + oy * M[c * 4 + 3]
+ }
+ gl.uniformMatrix4fv(u.uM, false, M)
+ reportBack(M)
+ gl.uniformMatrix3fv(u.uFx, false, [fxM[0][0], fxM[1][0], fxM[2][0], fxM[0][1], fxM[1][1], fxM[2][1], fxM[0][2], fxM[1][2], fxM[2][2]])
+ gl.uniform3f(u.uFxO, fxM[0][3], fxM[1][3], fxM[2][3])
+ gl.uniform2f(u.uCast, castX, castA)
+ // Mesa
+ gl.depthMask(true)
+ gl.uniform1i(u.uKind, 0)
+ gl.bindTexture(gl.TEXTURE_2D, tableTex || wood)
+ if (tableTex) { const ts = tableScale(); gl.uniform3f(u.uTab, ts / tableAspect, ts, 1) } else gl.uniform3f(u.uTab, 0, 0, 0)
+ gl.uniform4f(u.uRect, cam.target[0], cam.target[1], Math.max(W, 0.7) * 1.3, 1)
+ drawMesh(tableMesh)
+ if (!ready) { if (again) draw(); return }
+ // Sombras en la mesa: una muy pegada une el libro con la madera, la otra cae a un lado y abajo.
+ const O = BOOK.OVER
+ let bx0 = -W - O, bx1 = W + O
+ if (cover || v.kind === 'closed') {
+ const under = cover ? cover.under : v.under, reach = cover ? landWidth(cover.a) : 0
+ if (under > 0) { bx0 = -(W + O) * reach - BOOK.SPINE * (1 - reach); bx1 = W + O } else { bx0 = -W - O; bx1 = (W + O) * reach + BOOK.SPINE * (1 - reach) }
+ }
+ gl.enable(gl.BLEND)
+ gl.depthMask(false)
+ gl.disable(gl.DEPTH_TEST)
+ gl.uniform1i(u.uKind, 1)
+ for (const [dx, dy, soft, alpha] of [[-0.05, -0.062, 0.1, 0.5], [-0.02, -0.026, 0.04, 0.58], [-0.003, -0.004, 0.012, 0.75]]) {
+ gl.uniform4f(u.uRect, bx0 + dx, -0.5 - O + dy, bx1 + dx, 0.5 + O + dy)
+ gl.uniform1f(u.uSoft, soft)
+ gl.uniform1f(u.uAlpha, alpha)
+ drawMesh(tableMesh)
+ }
+ gl.disable(gl.BLEND)
+ gl.enable(gl.DEPTH_TEST)
+ gl.depthMask(true)
+ // Libro
+ if (cover) {
+ // Durante la apertura, la mitad sobre la que aterriza se extiende justo debajo de la cubierta.
+ const openV = anim.opening ? anim.to : anim.from, a = cover.a, o = smooth(0, 1, a)
+ const width = landWidth(a), land = { k: smooth(0.97, 1, a), width }
+ const sh = bookShapes(o, cover.under, land), m = {}
+ for (const [n, q] of Object.entries(sh)) if (q) m[n] = dynMesh(`b_${n}`, q)
+ const under = pageOn(openV, cover.under)
+ const pages = { [cover.under]: under, [-cover.under]: { i: null } }
+ drawBook(m, pages[-1], pages[1])
+ const size = BOOK.SPINE * Math.max(0, 1 - a * 2.5)
+ const lid = lidOf(anim.from.back || anim.to.back)
+ if (size > 0) drawSpine(cover.under, LID_Z, size, lid)
+ drawCover(cover.under, a, pageOn(openV, -cover.under), lid)
+ } else if (sheet) {
+ // La hoja lleva por delante la página vieja de su lado y por detrás la nueva del otro. Debajo ya se ve la nueva de su lado.
+ const m = staticMeshes(v), s0 = sheet.side
+ const pages = { [s0]: pageOn(anim.to, s0), [-s0]: pageOn(anim.from, -s0) }
+ drawBook(m, pages[-1], pages[1])
+ sheet.pts.forEach(p => { p.u = s0 > 0 ? p.t : 1 - p.t })
+ gl.uniform2f(u.uCast, 0, 0)
+ drawSheet(dynMesh('sheet', sheetShape(sheet.pts)), s0, pageOn(anim.from, s0), pageOn(anim.to, -s0))
+ } else if (v.kind === 'closed') {
+ drawBook(staticMeshes(v), null, null)
+ const lid = lidOf(v.back)
+ drawSpine(v.under, LID_Z, BOOK.SPINE, lid)
+ drawCover(v.under, 0, { i: null }, lid)
+ } else {
+ drawBook(staticMeshes(v), pageOn(v, -1), pageOn(v, 1))
+ }
+ if (again) draw()
+ }
+ function draw() { if (!raf && !dead) raf = requestAnimationFrame(paint) }
+
+ function startTurn(from, to, dir, drag) {
+ const fwd = dir > 0, r = rtl()
+ const ctl = { anims: [{ cancel() {} }], range: Math.max(200, rect.width * (from.focus || to.focus ? 0.9 : 0.5)) }
+ const shut = from.kind === 'closed' ? from : to.kind === 'closed' ? to : null, cover = !!shut
+ anim = {
+ ctl, from, to, p: 0, goal: 0, back: false, dragging: !!drag, last: performance.now(), camFrom: camNow || fitOf(from),
+ kind: cover ? 'cover' : 'sheet', dur: cover ? 1.3 : 0.75,
+ opening: to.kind !== 'closed', under: shut?.under ?? (r ? -1 : 1),
+ side: fwd === r ? -1 : 1, // lado del que sale la hoja: en der → izq, avanzar la levanta de la izquierda
+ }
+ ctl.cleanup = () => { if (anim?.ctl === ctl) finishAnim(!anim.back) }
+ if (drag) {
+ ctl.move = q => { if (anim?.ctl === ctl) { anim.goal = Math.max(0, Math.min(1, q)); draw() } }
+ ctl.release = (commit, done) => {
+ if (anim?.ctl !== ctl) return done?.(commit)
+ anim.dragging = false
+ anim.back = !commit
+ anim.done = done
+ anim.dur *= 0.7
+ // Lo arrastrado ya es lineal: se pasa a la curva de la animación sin saltos.
+ anim.p = inverseEase(anim.p)
+ anim.last = performance.now()
+ draw()
+ }
+ }
+ return ctl
+ }
+ const inverseEase = y => (y < 0.5 ? Math.sqrt(y / 2) : 1 - Math.sqrt((1 - y) / 2))
+
+ const ro = new ResizeObserver(draw)
+ ro.observe(container)
+ const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) }
+ document.addEventListener('visibilitychange', onVis)
+ canvas.addEventListener('webglcontextlost', e => {
+ e.preventDefault()
+ glEpoch++
+ if (anim) finishAnim(true)
+ textures.clear(); loading.clear(); queue.length = 0; statics = null; staticKey = ''; backTex = null; tableTex = null
+ })
+ canvas.addEventListener('webglcontextrestored', () => {
+ initGL()
+ loadTable()
+ want(0)
+ if (view) for (const i of pagesOf(view)) want(i)
+ draw()
+ })
+ initGL()
+ loadTable()
+ if (!still) tilt.permit()
+
+ const api = {
+ // Devuelve el control del giro si lo hay: el lector lo usa para el arrastre y para acabarlo antes de tiempo.
+ show(group, dir = 0, drag = false) {
+ if (!group.length) return null
+ if (anim) finishAnim(!anim.back)
+ const next = viewOf(group), first = group[0], last = group.at(-1)
+ keepFrom = first - 3
+ keepTo = last + 4
+ pagesOf(next).forEach(want)
+ want(0) // la portada da el color de la tapa y de la contraportada, aunque se empiece a mitad de capítulo
+ if (!backNear && last + 4 >= total - 1) { backNear = true; clearTimeout(backTimer); backTimer = setTimeout(ensureBack, 1200) } // cuando acabe el giro
+ for (let k = 1; k <= 3; k++) { want(last + k); want(first - k) }
+ trim()
+ const prev = view
+ let ctl = null
+ if (prev && dir && prev.key !== next.key && !(prev.kind === 'closed' && next.kind === 'closed') && W > 0 && hasAll(next)) ctl = startTurn(prev, next, dir, drag)
+ else if (prev && dir && prev.key === next.key && prev.focus !== next.focus && W > 0 && camNow) camMove = { from: camNow, t0: performance.now(), dur: 450 } // la otra cara de la misma pareja
+ if (ctl) anim.to = next
+ else view = next
+ draw()
+ return ctl
+ },
+ // Fin del capítulo: la tapa de atrás se cierra sobre las hojas. El libro queda boca abajo y con el lomo al otro lado.
+ showBack(dir = 0, drag = false) {
+ if (anim) finishAnim(!anim.back)
+ want(0)
+ backNear = true
+ ensureBack()
+ const next = { kind: 'closed', back: true, under: rtl() ? 1 : -1, focus: 0, key: 'e' }, prev = view
+ const ctl = prev && dir && prev.kind !== 'closed' && W > 0 && textures.has(0) ? startTurn(prev, next, dir, drag) : null
+ if (!ctl) view = next
+ draw()
+ return ctl
+ },
+ setZoom(s, x, y) { zoom = { s, x, y }; draw() },
+ setFilter(css) { fxM = filterMatrix(css); draw() },
+ invalidate(i) {
+ const t = textures.get(i)
+ if (t) { gl.deleteTexture(t.tex); textures.delete(i) }
+ want(i)
+ },
+ draw,
+ // Para las pruebas.
+ debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }),
+ destroy() {
+ dead = true
+ cancelAnimationFrame(raf)
+ clearTimeout(uploadTimer)
+ clearTimeout(backTimer)
+ ro.disconnect()
+ tilt.stop()
+ document.removeEventListener('visibilitychange', onVis)
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
+ canvas.remove()
+ },
+ }
+ canvas.b3d = api // para las pruebas
+ return api
+}
diff --git a/app/public/index.html b/app/public/index.html
index baef472..4237778 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -3,6 +3,7 @@
+
Manga Reader
diff --git a/app/public/style.css b/app/public/style.css
index 5bcda77..e4d5e54 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
+.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
+.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
+.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
+.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
+.chapters .voltoggle > span { display: grid; gap: 2px; }
+.chapters .voltoggle .meta { text-align: left; }
+.chapters .volhead > .btn { flex: none; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
@@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
+.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
+.tableprev[hidden] { display: none; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
@@ -450,3 +459,25 @@ body.reading #netbanner { display: none; }
.tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; }
+/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
+#b3d { position: fixed; inset: 0; z-index: 0; }
+#b3d[hidden] { display: none; }
+#b3d canvas { display: block; width: 100%; height: 100%; }
+.reader.b3d { background: #4a2e19; }
+.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
+.reader.b3d > #pages img { visibility: hidden; }
+/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
+.reader.b3d > #pages > * { pointer-events: none; }
+/* El final del capítulo, sobre la contraportada y con sus colores. */
+.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
+ opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
+.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
+.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
+.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
+ background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
+.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
+.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
+/* Para recortar se ve la página plana. */
+.reader.b3d.b3dcrop { background: #000; }
+.reader.b3d.b3dcrop > #b3d { display: none; }
+.reader.b3d.b3dcrop > #pages img { visibility: visible; }
diff --git a/app/public/sw.js b/app/public/sw.js
index 4073a16..bc4a48a 100644
--- a/app/public/sw.js
+++ b/app/public/sw.js
@@ -2,7 +2,7 @@
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
-const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
+const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
diff --git a/app/server.js b/app/server.js
index 1c6d7a6..6d05d65 100644
--- a/app/server.js
+++ b/app/server.js
@@ -15,6 +15,8 @@ import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js'
+import { setupVolumes } from './volumes.js'
+import { setupCredits } from './credits.js'
import { setupBooks, isBookId } from './books.js'
const PORT = Number(process.env.PORT || 3352)
@@ -771,6 +773,45 @@ function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
+// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido.
+const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024
+const tableType = b => {
+ if (b.length < 16) return null
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
+ if (b.readUInt32BE(0) === 0x89504e47) return 'image/png'
+ if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
+ return null
+}
+fs.mkdirSync(TABLE_DIR, { recursive: true })
+const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`)
+const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } }
+async function tableRoute(method, req, res, user, q) {
+ const file = tableFile(user.id)
+ if (method === 'GET') {
+ let buf
+ try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') }
+ res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' })
+ return res.end(buf)
+ }
+ if (method === 'DELETE') {
+ fs.rmSync(file, { force: true })
+ return json(res, 200, { tableV: 0 })
+ }
+ if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
+ let size = 0
+ const chunks = []
+ for await (const c of req) {
+ size += c.length
+ if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande')
+ chunks.push(c)
+ }
+ const buf = Buffer.concat(chunks)
+ if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP')
+ const tmp = `${file}.tmp`
+ fs.writeFileSync(tmp, buf)
+ fs.renameSync(tmp, file)
+ return json(res, 200, { tableV: tableVersion(user.id) })
+}
async function readBody(req) {
let size = 0
const chunks = []
@@ -801,6 +842,7 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
+ .replace(' ', ` `)
.replace(' ', ` `)
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
@@ -836,7 +878,8 @@ async function route(req, res) {
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
- return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
+ const u = getUser(req)
+ return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(ip)
@@ -873,12 +916,14 @@ async function route(req, res) {
// Todo lo demás requiere sesión.
const user = requireUser(req)
+ if (p === '/api/table') return tableRoute(method, req, res, user, q)
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
let rawUrl = req.url
try { rawUrl = decodeURIComponent(req.url) } catch {}
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
if ((await features.route(p, method, q, req, res, user)) !== false) return
+ if ((await credits.route(p, method, req, res, user)) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) {
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
@@ -948,6 +993,14 @@ async function route(req, res) {
}
let m
+ if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
+ return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
+ }
+ if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
+ let vol
+ try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
+ return volumes.sendFace(res, m[1], vol, m[3])
+ }
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
let manga, chapters, offline = false
try {
@@ -966,7 +1019,7 @@ async function route(req, res) {
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
- return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
+ return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, hideCredits: credits.enabled(user.id, m[1]), download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
}
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
@@ -1116,6 +1169,8 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
+const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
+const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
diff --git a/app/volumes.js b/app/volumes.js
new file mode 100644
index 0000000..cef66dd
--- /dev/null
+++ b/app/volumes.js
@@ -0,0 +1,706 @@
+// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
+// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
+// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
+// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
+// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
+// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
+// Lo descargado queda en /tomes//-.img.
+import fs from 'node:fs'
+import path from 'node:path'
+import crypto from 'node:crypto'
+
+const DAY = 24 * 3600e3
+const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
+const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
+const AMAZON = 'https://m.media-amazon.com/images/P'
+const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
+const MAX_IMAGE = 30 * 1024 * 1024
+const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
+const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
+const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
+
+// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
+export const volKey = v => {
+ const s = String(v ?? '').trim()
+ if (!s || s.toLowerCase() === 'none') return null
+ return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
+}
+const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
+const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
+
+// Título comparable: sin signos, espacios ni diferencias de anchura.
+export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
+export function ndlVolume(s) {
+ const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
+ return m ? String(parseInt(m[1], 10)) : null
+}
+export function isbn10(raw) {
+ const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
+ if (/^\d{9}[\dX]$/.test(s)) return s
+ if (!/^978\d{10}$/.test(s)) return null
+ const body = s.slice(3, 12)
+ let sum = 0
+ for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
+ const c = (11 - (sum % 11)) % 11
+ return body + (c === 10 ? 'X' : String(c))
+}
+const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim()
+
+// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
+// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
+export function pickIsbns(xmlPages, titles, year) {
+ const keys = new Set(titles.map(titleKey).filter(Boolean))
+ const groups = new Map()
+ for (const xml of xmlPages) {
+ for (const [, it] of xml.matchAll(/- ([\s\S]*?)<\/item>/g)) {
+ if (!keys.has(titleKey(xmlText(/
([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
+ const vol = ndlVolume(xmlText(/([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
+ if (!vol) continue
+ const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+) isbn10(m[1])).filter(Boolean)
+ if (!isbns.length) continue
+ const author = titleKey(xmlText(/([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
+ const when = parseInt(/]*>(\d{4})/.exec(it)?.[1], 10) || null
+ if (!groups.has(author)) groups.set(author, new Map())
+ const g = groups.get(author)
+ if (!g.has(vol)) g.set(vol, { isbns: [], when })
+ const e = g.get(vol)
+ for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
+ if (when && (!e.when || when < e.when)) e.when = when
+ }
+ }
+ const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
+ const best = [...groups.values()]
+ .filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
+ .sort((a, b) => b.size - a.size)[0]
+ if (!best) return {}
+ return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
+}
+
+// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
+// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
+// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
+const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
+export function pickEnglishIsbns(docs, titles, vol) {
+ const out = []
+ for (const title of titles) {
+ const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
+ for (const d of docs) {
+ if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
+ for (const x of d.isbn || []) {
+ const i = isbn10(x)
+ if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
+ }
+ }
+ }
+ return out
+}
+const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
+
+// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
+export function pickCovers(records, lang) {
+ const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
+ const out = {}
+ for (const r of records) {
+ const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
+ if (!vol || !file) continue
+ if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
+ }
+ return out
+}
+
+function imageKind(b) {
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
+ if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
+ if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
+ return null
+}
+
+/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
+ * Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
+export function strayNums(nums) {
+ const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
+ if (known.length < 3) return new Set()
+ const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
+ const center = middle(known)
+ const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
+ const limit = Math.max(4 * spread, 5)
+ const far = known.filter(n => Math.abs(n - center) > limit)
+ return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
+}
+
+/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
+export function cleanVolMap(map) {
+ const byVol = new Map()
+ for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
+ const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
+ const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
+ const usual = sizes[(sizes.length - 1) >> 1]
+ const out = {}
+ for (const [v, nums] of byVol) {
+ const size = whole(nums)
+ if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
+ const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
+ for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
+ }
+ return out
+}
+
+/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
+export function volMapsAgree(a, b) {
+ const shared = Object.keys(a).filter(n => n in b)
+ if (shared.length < 5) return true
+ return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
+}
+
+/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
+export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
+ const merged = {}
+ // M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
+ const shown = new Set(Object.values(mi).map(Number))
+ const inMu = new Set(Object.values(mu).map(Number))
+ const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
+ for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
+ if (!volMapsAgree(src, merged)) continue
+ for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
+ }
+ return merged
+}
+
+/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
+export function parseMuVolumes(rows) {
+ const votes = {}
+ for (const { volume, chapter } of rows) {
+ const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
+ if (!vol) continue
+ const ch = String(chapter ?? '').trim()
+ const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
+ const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
+ const nums = one ? [parseFloat(one[1])] : []
+ if (range) {
+ const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
+ if (hi >= lo && hi - lo < 50) {
+ nums.push(lo, hi)
+ for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
+ }
+ }
+ for (const n of new Set(nums)) {
+ const count = (votes[n] ||= {})
+ count[vol] = (count[vol] || 0) + 1
+ }
+ }
+ const map = {}
+ for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
+ return map
+}
+
+/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
+ * si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
+export function orderedMuMap(map, total = 0) {
+ const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
+ const vol = nums.map(n => parseInt(map[n], 10))
+ // La subsecuencia no decreciente más larga.
+ const len = vol.map(() => 1), from = vol.map(() => -1)
+ let end = 0
+ vol.forEach((v, i) => {
+ for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
+ if (len[i] > len[end]) end = i
+ })
+ const out = {}
+ for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
+ const size = Object.keys(out).length
+ return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
+}
+
+/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
+ * antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
+export function fillVolGaps(map, nums) {
+ const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
+ const out = { ...map }
+ for (const key of nums) {
+ if (key == null || key in map) continue
+ const n = parseFloat(key)
+ const prev = anchors.filter(a => a < n).at(-1)
+ const next = anchors.find(a => a > n)
+ if (next === undefined) continue
+ const after = map[next]
+ if (prev === undefined) {
+ if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
+ continue
+ }
+ const before = map[prev]
+ if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
+ }
+ return out
+}
+
+export const ownVolMap = chapters => {
+ const map = {}
+ for (const c of chapters) {
+ const n = numKey(c.num), v = volKey(c.volume)
+ if (n && v && !(n in map)) map[n] = v
+ }
+ return map
+}
+
+/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
+export function numbersRestart(chapters) {
+ const seen = new Map()
+ let repeated = 0
+ for (const c of chapters) {
+ const n = numKey(c.num), v = volKey(c.volume)
+ if (!n || !v) continue
+ if (seen.has(n) && seen.get(n) !== v) repeated++
+ else seen.set(n, v)
+ }
+ return repeated >= 3
+}
+
+/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
+ * y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
+export function assignVolumes(chapters, map = {}) {
+ const vols = chapters.map(c => map[numKey(c.num)] ?? null)
+ vols.forEach((v, i) => {
+ const n = parseFloat(chapters[i].num)
+ if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
+ })
+ let last = -1
+ vols.forEach((v, i) => {
+ if (!v) return
+ if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
+ last = i
+ })
+ return vols
+}
+
+/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
+ * (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
+export function dropMisplaced(vols, maxRun = 3) {
+ const out = [...vols]
+ if (!vols.every(v => !v || /^\d+$/.test(v))) return out
+ const runsOf = () => {
+ const runs = []
+ out.forEach((v, i) => {
+ if (!v) return
+ if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
+ else runs.push({ v: parseInt(v, 10), at: [i] })
+ })
+ return runs
+ }
+ // De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
+ for (;;) {
+ const runs = runsOf()
+ const wrong = runs.filter((r, j) => {
+ if (r.at.length > maxRun) return false
+ const [a, b] = [runs[j - 1], runs[j + 1]]
+ if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
+ const near = a || b
+ return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
+ })
+ if (!wrong.length) return out
+ const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
+ for (const i of hit.at) out[i] = null
+ }
+}
+
+/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
+ * known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
+export function placeLoose(chapters, vols, known = []) {
+ const out = dropMisplaced(vols)
+ const isNum = v => /^\d+$/.test(v ?? '')
+ const floorOf = i => Math.floor(parseFloat(chapters[i].num))
+ // Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
+ const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
+ const spread = (from, to, names) => {
+ let start = from
+ names.forEach((name, j) => {
+ const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
+ for (let i = start; i < end; i++) out[i] = name
+ start = Math.max(start, end)
+ })
+ }
+ const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
+ const assigned = out.flatMap((v, i) => (v ? [i] : []))
+ if (!assigned.length) {
+ if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
+ return out
+ }
+ const count = {}
+ for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
+ const sizes = Object.values(count).sort((a, b) => a - b)
+ const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
+ const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
+ const free = []
+ for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
+ return free
+ }
+ // Antes del primer tomo.
+ const first = assigned[0]
+ if (first) {
+ const head = out[first]
+ const free = isNum(head) ? between(0, parseInt(head, 10)) : []
+ const n = Math.min(free.length, Math.round(first / usual))
+ if (n) spread(0, first, free.slice(-n))
+ else for (let i = 0; i < first; i++) out[i] = head
+ }
+ // Entre dos tomos.
+ for (let a = 0; a < assigned.length - 1; a++) {
+ const [l, r] = [assigned[a], assigned[a + 1]]
+ if (r - l < 2) continue
+ const [left, right] = [out[l], out[r]]
+ const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
+ if (free.length) {
+ // Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
+ const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
+ const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
+ const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
+ for (let i = l + 1; i < from; i++) out[i] = left
+ for (let i = to; i < r; i++) out[i] = right
+ spread(from, to, free)
+ count[left] += from - l - 1
+ count[right] += r - to
+ continue
+ }
+ if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
+ for (let i = l + 1; i < r; i++) out[i] = left
+ continue
+ }
+ // Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
+ const whole = []
+ for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
+ if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
+ const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
+ const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
+ const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
+ for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
+ count[left] += at - l - 1
+ count[right] += r - at
+ }
+ // Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
+ const end = assigned.at(-1)
+ const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
+ const rest = chapters.length - end - 1
+ if (free.length && rest) {
+ const fits = rest <= free.length * usual * 1.3
+ spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
+ }
+ return out
+}
+
+/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
+function numberedListCount(text, from) {
+ let depth = 0, bars = 0
+ for (let i = from; i < text.length - 1; i++) {
+ const two = text.slice(i, i + 2)
+ if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
+ if (--depth < 0) break
+ i++
+ } else if (text[i] === '|' && depth === 0) bars++
+ }
+ return bars
+}
+
+/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
+ * "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
+ * empezar por el tomo 1, se ignoran. */
+export function parseWikiVolumes(text) {
+ const map = {}, seen = new Set()
+ let next = 1
+ for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
+ const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
+ if (!vol) continue
+ if (seen.has(vol)) break
+ seen.add(vol)
+ const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
+ if (!fields.length) continue
+ const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
+ for (const f of fields) {
+ const start = f.index + f[0].length
+ const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
+ const body = block.slice(start, end < 0 ? undefined : start + end)
+ const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
+ if (numbered.length) {
+ for (const m of numbered) {
+ const first = parseInt(m[1], 10)
+ for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
+ }
+ continue
+ }
+ const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
+ if (range) {
+ for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
+ continue
+ }
+ for (const line of body.split('\n')) {
+ if (!/^\s*[*#]/.test(line)) continue
+ const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
+ add(own ? parseInt(own[1], 10) : next)
+ }
+ }
+ }
+ return map
+}
+
+/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
+export function workTitle(title) {
+ const t = String(title || '').trim()
+ const base = t
+ .replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
+ .replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
+ .replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
+ return base.length >= 3 ? base : t
+}
+
+/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
+export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
+
+export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
+
+export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
+ const DIR = path.join(DATA_DIR, 'tomes')
+
+ const fetchImage = async url => {
+ net.guard()
+ const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
+ if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
+ if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
+ if (!r.ok) return null
+ const b = Buffer.from(await r.arrayBuffer())
+ return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
+ }
+
+ // ---------- Lo que dice MangaDex de la obra ----------
+ async function mdVolumes(mdId, lang) {
+ return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
+ const [agg, mangaData] = await Promise.all([
+ md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
+ md(`/manga/${mdId}`, {}, 30 * 60e3),
+ ])
+ const byNum = {}
+ const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
+ for (const v of vols) {
+ const vol = volKey(v.volume)
+ if (!vol) continue
+ const inVol = Object.values(v.chapters || {})
+ const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
+ for (const c of inVol) {
+ const n = numKey(c.chapter)
+ if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
+ }
+ }
+ const records = []
+ for (let offset = 0; offset < 1000; offset += 100) {
+ const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
+ records.push(...r.data)
+ if (offset + 100 >= r.total || !r.data.length) break
+ }
+ const a = mangaData.data.attributes
+ const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
+ const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
+ const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
+ return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
+ }, true)
+ }
+
+ let gate = Promise.resolve()
+ const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
+
+ // ---------- Otras fuentes del reparto ----------
+ async function wikiPages(titles) {
+ if (!titles.length) return []
+ await turn() // Wikipedia corta a quien va demasiado rápido
+ net.guard()
+ const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
+ const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
+ let r = await get()
+ if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
+ await new Promise(res => setTimeout(res, 3000))
+ r = await get()
+ }
+ if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
+ return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
+ }
+ async function wikiSearch(names) {
+ await turn()
+ net.guard()
+ const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
+ const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
+ if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
+ const wanted = new Set(names.map(wikiKey))
+ return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
+ }
+ function wikiMap(mangaId, names) {
+ return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
+ const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
+ let best = {}
+ let pages = await wikiPages(titles)
+ if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
+ for (const text of pages) {
+ // One Piece y otras largas reparten la lista en subpáginas.
+ const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
+ let found = parseWikiVolumes(text)
+ for (let i = 0; i < subs.length; i += 10) {
+ for (const sub of await wikiPages(subs.slice(i, i + 10))) {
+ const part = parseWikiVolumes(sub)
+ found = { ...part, ...found }
+ }
+ }
+ if (Object.keys(found).length > Object.keys(best).length) best = found
+ }
+ return best
+ }, true)
+ }
+ function m440Map(mangaId, names) {
+ return stored(`mimap:${mangaId}`, DAY, async () => {
+ for (const name of names.slice(0, 2)) {
+ const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
+ if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
+ }
+ return {}
+ }, true)
+ }
+
+ // ---------- ISBN de cada tomo ----------
+ async function ndlSearch(title) {
+ const pages = []
+ for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
+ await turn()
+ net.guard()
+ const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
+ const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
+ if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
+ const xml = await r.text()
+ pages.push(xml)
+ if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
+ }
+ return pages
+ }
+ async function isbnsOf(mdId) {
+ return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
+ const d = await mdVolumes(mdId)
+ if (d.original !== 'ja' || !d.jaTitles.length) return {}
+ for (const t of d.jaTitles.slice(0, 3)) {
+ const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
+ if (Object.keys(found).length) return found
+ }
+ return {}
+ }, true)
+ }
+
+ async function englishIsbns(mdId, vol) {
+ const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
+ if (!titles.length) return []
+ await turn()
+ net.guard()
+ const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
+ const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
+ if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
+ return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
+ }
+
+ // ---------- Imágenes de un tomo ----------
+ async function fetchFace(mdId, vol, side) {
+ const volNum = /^\d+$/.test(vol) ? vol : null
+ if (side === 'front') {
+ const c = (await mdVolumes(mdId)).covers[vol]
+ if (c) {
+ const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
+ if (b) return b
+ }
+ }
+ if (!volNum) return null
+ for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
+ const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
+ if (b) return b
+ }
+ if (side === 'back') {
+ for (const isbn of await englishIsbns(mdId, volNum)) {
+ const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
+ if (b) return b
+ }
+ }
+ return null
+ }
+ const inflight = new Map()
+ function face(mdId, vol, side) {
+ const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
+ const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
+ if (fs.existsSync(file)) return Promise.resolve(file)
+ const key = `tome:${mdId}:${vol}:${label}`
+ if (!inflight.has(key)) {
+ // Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
+ inflight.set(key, stored(key, 14 * DAY, async () => {
+ const b = await fetchFace(mdId, vol, side)
+ if (!b) return { found: false }
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ const tmp = `${file}.${process.pid}.tmp`
+ fs.writeFileSync(tmp, b)
+ fs.renameSync(tmp, file)
+ return { found: true }
+ }, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
+ }
+ return inflight.get(key)
+ }
+
+ // ---------- API ----------
+ async function list(mangaId, lang) {
+ const { manga, chapters } = await fetchMangaFull(mangaId, lang)
+ if (hasNoVolumes(manga.type)) return []
+ const source = sourceOf(mangaId)
+ // Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
+ const found = await mdMatches(mangaId).catch(() => [])
+ const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
+ const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
+ const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
+ const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
+ if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
+ const mdId = source === 'md' ? mangaId : best?.id || null
+ const data = editions.find(e => e.id === mdId)?.data || null
+ const own = cleanVolMap(ownVolMap(chapters))
+ let vols
+ if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
+ else {
+ const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
+ const [wiki, mi] = await Promise.all([
+ wikiMap(mangaId, names).catch(() => ({})),
+ source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
+ ])
+ const byNum = cleanVolMap(best?.data.byNum || {})
+ const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
+ // Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
+ const known = { ...main.wiki, ...main.md }
+ const missing = chapters.filter(c => !(numKey(c.num) in known)).length
+ const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
+ vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
+ }
+ vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
+ const groups = new Map()
+ vols.forEach((vol, i) => {
+ if (!vol) return
+ if (!groups.has(vol)) groups.set(vol, [])
+ groups.get(vol).push(chapters[i].id)
+ })
+ return [...groups.keys()].sort(volOrder).map(vol => {
+ const cover = data?.covers[vol]
+ return {
+ vol,
+ chapters: groups.get(vol),
+ thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
+ front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
+ back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
+ }
+ })
+ }
+ async function sendFace(res, mdId, vol, side) {
+ if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
+ const file = await face(mdId, vol, side)
+ if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
+ const head = Buffer.alloc(16)
+ const fd = fs.openSync(file, 'r')
+ fs.readSync(fd, head, 0, 16, 0)
+ fs.closeSync(fd)
+ res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
+ fs.createReadStream(file).pipe(res)
+ }
+ return { list, sendFace }
+}