diff --git a/hosts/manatee/modules/komga/komga-reader.html b/hosts/manatee/modules/komga/komga-reader.html index 37d6419..76c2084 100644 --- a/hosts/manatee/modules/komga/komga-reader.html +++ b/hosts/manatee/modules/komga/komga-reader.html @@ -834,6 +834,7 @@ let fitMode = 'width'; let naturalW = 0, naturalH = 0; let pageMode = 'single'; // 'single' or 'double' + let isPdf = false; // current book is a PDF (rendered client-side via pdf.js) const el = id => document.getElementById(id); const $loginScreen = el('login-screen'); @@ -883,6 +884,56 @@ img.src = url; } + // ══════════════════════════════════════════════ + // PDF SUPPORT (pdf.js, lazy-loaded) + // + // Komga serves PDF pages as raw application/pdf (one single-page + // PDF per /pages/{n} request), which an can't decode. For + // PDF books we fetch that mini-PDF and rasterize it to a canvas + // with pdf.js, then feed it through the same zoom/pan/double-page + // machinery as image pages. + // ══════════════════════════════════════════════ + const PDFJS_VERSION = '3.11.174'; + let pdfjsReady = null; + + function ensurePdfjs() { + if (window.pdfjsLib) return Promise.resolve(); + if (pdfjsReady) return pdfjsReady; + pdfjsReady = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.min.js`; + s.onload = () => { + window.pdfjsLib.GlobalWorkerOptions.workerSrc = + `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.worker.min.js`; + resolve(); + }; + s.onerror = () => { pdfjsReady = null; reject(new Error('pdf.js failed to load')); }; + document.head.appendChild(s); + }); + return pdfjsReady; + } + + // Fetch page `pageNum` (a single-page PDF) and render it to a fresh + // offscreen canvas at a resolution that stays crisp when zoomed. + async function renderPdfPage(pageNum) { + const url = serverUrl.replace(/\/$/, '') + `/api/v1/books/${currentBook.id}/pages/${pageNum}`; + const buf = await fetch(url, { headers: { 'Authorization': authHeader } }).then(r => r.arrayBuffer()); + const doc = await window.pdfjsLib.getDocument({ data: buf }).promise; + try { + const page = await doc.getPage(1); + const base = page.getViewport({ scale: 1 }); + const scale = Math.min(4, Math.max(1, 2000 / base.width)); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(viewport.width); + canvas.height = Math.ceil(viewport.height); + await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; + return canvas; + } finally { + doc.destroy(); + } + } + // ══════════════════════════════════════════════ // LOGIN // ══════════════════════════════════════════════ @@ -1139,6 +1190,7 @@ async function openBook(book) { currentBook = book; currentPage = 1; + isPdf = book.media?.mediaType === 'application/pdf'; document.querySelectorAll('.list-item').forEach(e => e.classList.remove('active')); try { @@ -1150,6 +1202,16 @@ if (bd.readProgress && !bd.readProgress.completed) currentPage = bd.readProgress.page || 1; } catch(e) {} + if (isPdf) { + try { + await ensurePdfjs(); + } catch(e) { + console.error(e); + $readerPlaceholder.querySelector('p').textContent = 'Failed to load the PDF renderer (needs network for pdf.js).'; + return; + } + } + $readerPlaceholder.style.display = 'none'; $imageContainer.style.display = 'block'; $readerControls.style.display = 'flex'; @@ -1190,6 +1252,8 @@ currentPage = pageNum; $loadingOverlay.style.display = 'flex'; + if (isPdf) { loadPdfPage(pageNum); return; } + if (pageMode === 'double') { const leftPage = currentPage; const rightPage = currentPage + 1 <= totalPages ? currentPage + 1 : null; @@ -1256,6 +1320,69 @@ } } + function loadPdfPage(pageNum) { + if (pageMode === 'double') { + const leftPage = currentPage; + const rightPage = currentPage + 1 <= totalPages ? currentPage + 1 : null; + + $pageIndicator.textContent = rightPage + ? `${leftPage}-${rightPage} / ${totalPages}` + : `${leftPage} / ${totalPages}`; + + const jobs = [renderPdfPage(leftPage)]; + if (rightPage) jobs.push(renderPdfPage(rightPage)); + + Promise.all(jobs) + .then(canvases => { + const left = canvases[0]; + const right = canvases[1] || null; + + const lw = left.width, lh = left.height; + const rw = right ? right.width : 0; + const rh = right ? right.height : 0; + const canvasW = lw + (right ? rw : 0); + const canvasH = Math.max(lh, rh || 0); + + $comicCanvas.width = canvasW; + $comicCanvas.height = canvasH; + const ctx = $comicCanvas.getContext('2d'); + ctx.fillStyle = '#0a0a0c'; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.drawImage(left, 0, (canvasH - lh) / 2); + if (right) ctx.drawImage(right, lw, (canvasH - rh) / 2); + + const prevW = naturalW; + naturalW = canvasW; + naturalH = canvasH; + showElement($comicCanvas); + + if (!prevW) applyFitMode(); else centerAtCurrentScale(); + $loadingOverlay.style.display = 'none'; + updateReadProgress(rightPage || leftPage); + }) + .catch(() => { $loadingOverlay.style.display = 'none'; }); + } else { + $pageIndicator.textContent = `${currentPage} / ${totalPages}`; + + renderPdfPage(currentPage) + .then(canvas => { + $comicCanvas.width = canvas.width; + $comicCanvas.height = canvas.height; + $comicCanvas.getContext('2d').drawImage(canvas, 0, 0); + + const prevW = naturalW; + naturalW = canvas.width; + naturalH = canvas.height; + showElement($comicCanvas); + + if (!prevW) applyFitMode(); else centerAtCurrentScale(); + $loadingOverlay.style.display = 'none'; + updateReadProgress(currentPage); + }) + .catch(() => { $loadingOverlay.style.display = 'none'; }); + } + } + function pageStep() { return pageMode === 'double' ? 2 : 1; }