Render PDF from current page outward on zoom/re-render
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s

renderPdf now takes an optional pivotPage. Pass 1 builds the full DOM
and sizes all canvases (instant). Then scrolls to the pivot page
immediately so the user stays in place. Pass 2 renders pixels from
pivot→end, then pivot-1→start.

reRenderPdf detects the current visible page before re-rendering and
passes it as pivot, so zoom no longer resets scroll position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-04-05 18:50:01 +02:00
parent 20a1b9a889
commit 1cf3f730ea

View file

@ -3010,7 +3010,7 @@ async function _parsePdfOutline(pdf, items, depth) {
return result; return result;
} }
async function renderPdf(arrayBuffer, contentEl, scaleOverride) { async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
const myGen = ++_pdfRenderGen; const myGen = ++_pdfRenderGen;
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise; const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise;
if (_pdfRenderGen !== myGen) return null; if (_pdfRenderGen !== myGen) return null;
@ -3031,7 +3031,6 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
contentEl.innerHTML = ''; contentEl.innerHTML = '';
// Viewport wrapper: CSS zoom controls display scale without re-rendering
const pdfVp = document.createElement('div'); const pdfVp = document.createElement('div');
pdfVp.id = 'pdf-viewport'; pdfVp.id = 'pdf-viewport';
contentEl.appendChild(pdfVp); contentEl.appendChild(pdfVp);
@ -3039,15 +3038,17 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
const containerWidth = readerSettings.pdfSpread const containerWidth = readerSettings.pdfSpread
? contentEl.clientWidth - 32 ? contentEl.clientWidth - 32
: Math.min(contentEl.clientWidth - 32, 900); : Math.min(contentEl.clientWidth - 32, 900);
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const dpr = window.devicePixelRatio || 1;
// Pass 1: build DOM structure and size all canvases (no pixel rendering yet)
const pageEntries = [];
let spreadContainer = null; let spreadContainer = null;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const page = await pdf.getPage(pageNum); const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1}); const naturalVp = page.getViewport({scale: 1});
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
// Zoom baked into canvas resolution — no CSS zoom, stays sharp at any DPR
const scale = scaleOverride != null ? scaleOverride const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100); : Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100);
const viewport = page.getViewport({scale}); const viewport = page.getViewport({scale});
@ -3056,12 +3057,9 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
wrapper.className = 'pdf-page-wrapper'; wrapper.className = 'pdf-page-wrapper';
wrapper.id = `pdf-page-${pageNum}`; wrapper.id = `pdf-page-${pageNum}`;
// Inner container gives canvas + text layer a shared position:relative origin,
// independent of the outer flex wrapper's centering.
const inner = document.createElement('div'); const inner = document.createElement('div');
inner.className = 'pdf-page-inner'; inner.className = 'pdf-page-inner';
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.className = 'pdf-page'; canvas.className = 'pdf-page';
canvas.width = Math.round(viewport.width * dpr); canvas.width = Math.round(viewport.width * dpr);
@ -3087,14 +3085,29 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
else pdfVp.appendChild(wrapper); else pdfVp.appendChild(wrapper);
} }
const ctx = canvas.getContext('2d'); pageEntries.push({page, canvas, viewport});
ctx.scale(dpr, dpr);
await page.render({canvasContext: ctx, viewport}).promise;
// Text layer disabled — re-enable once overlay rendering is resolved
} }
pdfTotalPages = pdf.numPages; pdfTotalPages = pdf.numPages;
// Scroll to pivot page immediately — DOM and canvas sizes are set
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
const pivotEl = document.getElementById(`pdf-page-${pivot}`);
if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'});
// Pass 2: render pixels from pivot outward (pivot→end, then pivot-1→start)
const order = [];
for (let i = pivot - 1; i < pdf.numPages; i++) order.push(i);
for (let i = pivot - 2; i >= 0; i--) order.push(i);
for (const idx of order) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const {page, canvas, viewport} = pageEntries[idx];
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
await page.render({canvasContext: ctx, viewport}).promise;
}
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
} }
@ -3698,10 +3711,6 @@ function toggleSettingsPanel() {
} }
async function applyPdfZoom(newZoom) { async function applyPdfZoom(newZoom) {
const contentEl2 = $('reader-content');
const fraction = contentEl2
? contentEl2.scrollTop / (contentEl2.scrollHeight - contentEl2.clientHeight || 1)
: 0;
readerSettings.pdfZoom = newZoom; readerSettings.pdfZoom = newZoom;
const zoomRange = document.getElementById('rs-zoom'); const zoomRange = document.getElementById('rs-zoom');
const zoomVal = document.getElementById('rs-zoom-val'); const zoomVal = document.getElementById('rs-zoom-val');
@ -3712,25 +3721,32 @@ async function applyPdfZoom(newZoom) {
pdfSmartZoomPage(pdfCurrentPage); pdfSmartZoomPage(pdfCurrentPage);
} else { } else {
await reRenderPdf(); await reRenderPdf();
// Wait for two animation frames so the browser finishes layout
// before reading scrollHeight for position restoration
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (contentEl2 && fraction > 0) {
contentEl2.scrollTop = fraction * (contentEl2.scrollHeight - contentEl2.clientHeight);
} }
} }
function _currentScrollPage(contentEl) {
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
const viewTop = contentEl.scrollTop;
for (const w of wrappers) {
if (w.offsetTop + w.offsetHeight > viewTop) {
const n = parseInt(w.id.replace('pdf-page-', ''), 10);
if (n) return n;
}
}
return pdfCurrentPage || 1;
} }
async function reRenderPdf() { async function reRenderPdf() {
if (!currentPdfBuffer) return; if (!currentPdfBuffer) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl);
const overlay = $('reader-overlay'); const overlay = $('reader-overlay');
const loadingEl = document.createElement('div'); const loadingEl = document.createElement('div');
loadingEl.className = 'pdf-loading-overlay'; loadingEl.className = 'pdf-loading-overlay';
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>'; loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
if (overlay) overlay.appendChild(loadingEl); if (overlay) overlay.appendChild(loadingEl);
await renderPdf(currentPdfBuffer, contentEl); await renderPdf(currentPdfBuffer, contentEl, undefined, pivot);
loadingEl.remove(); loadingEl.remove();
if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
} }