manatee: Add support for pdfs in komga-reader

This commit is contained in:
Alexander Heldt
2026-08-16 07:41:45 +00:00
parent cffaec5fe8
commit 6f68f7d9e6
@@ -834,6 +834,7 @@
let fitMode = 'width'; let fitMode = 'width';
let naturalW = 0, naturalH = 0; let naturalW = 0, naturalH = 0;
let pageMode = 'single'; // 'single' or 'double' 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 el = id => document.getElementById(id);
const $loginScreen = el('login-screen'); const $loginScreen = el('login-screen');
@@ -883,6 +884,56 @@
img.src = url; 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 <img> 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 // LOGIN
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
@@ -1139,6 +1190,7 @@
async function openBook(book) { async function openBook(book) {
currentBook = book; currentBook = book;
currentPage = 1; currentPage = 1;
isPdf = book.media?.mediaType === 'application/pdf';
document.querySelectorAll('.list-item').forEach(e => e.classList.remove('active')); document.querySelectorAll('.list-item').forEach(e => e.classList.remove('active'));
try { try {
@@ -1150,6 +1202,16 @@
if (bd.readProgress && !bd.readProgress.completed) currentPage = bd.readProgress.page || 1; if (bd.readProgress && !bd.readProgress.completed) currentPage = bd.readProgress.page || 1;
} catch(e) {} } 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'; $readerPlaceholder.style.display = 'none';
$imageContainer.style.display = 'block'; $imageContainer.style.display = 'block';
$readerControls.style.display = 'flex'; $readerControls.style.display = 'flex';
@@ -1190,6 +1252,8 @@
currentPage = pageNum; currentPage = pageNum;
$loadingOverlay.style.display = 'flex'; $loadingOverlay.style.display = 'flex';
if (isPdf) { loadPdfPage(pageNum); return; }
if (pageMode === 'double') { if (pageMode === 'double') {
const leftPage = currentPage; const leftPage = currentPage;
const rightPage = currentPage + 1 <= totalPages ? currentPage + 1 : null; 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() { function pageStep() {
return pageMode === 'double' ? 2 : 1; return pageMode === 'double' ? 2 : 1;
} }