PDFs: nur Seiten nahe der Ansicht zeichnen (ferne wieder freigeben), sofort öffnen; Öffnen lässt sich abbrechen (Knopf, Schließen, anderes Buch)
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Has been cancelled

Ein MEW-Band mit 1000 Seiten bereitete vorher jede Seite vor und zeichnete alle, bevor er sich
zeigte – Minuten und Gigabytes. e2e/pdf.spec.js mit einem 400-Seiten-PDF (e2e/make_pdf.py, pypdf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 14:57:03 +02:00
parent 7f9d6fc22a
commit 46e47df6d0
7 changed files with 251 additions and 37 deletions

View file

@ -45,6 +45,8 @@ User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.strin
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
// A big PDF (e2e/make_pdf.py): opening draws only the pages near the view.
execFileSync(PYTHON, [path.join(__dirname, 'make_pdf.py'), path.join(AUTH_DIR, 'big.pdf'), '400'], { stdio: 'inherit' });
// The book the reader tests read (e2e/make_epub.py).
execFileSync(PYTHON, [path.join(__dirname, 'make_epub.py'), path.join(AUTH_DIR, 'book.epub')], { stdio: 'inherit' });
// Two more for uploading several at once (navigation.spec.js).

12
e2e/make_pdf.py Normal file
View file

@ -0,0 +1,12 @@
"""A PDF of many pages for the reader tests (big scanned books): make_pdf.py OUT [PAGES]."""
import sys
from pypdf import PdfWriter
out, pages = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 400
w = PdfWriter()
for _ in range(pages):
w.add_blank_page(width=420, height=595)
w.add_metadata({'/Title': 'Dicker Band', '/Author': 'diora'})
with open(out, 'wb') as f:
w.write(f)

71
e2e/pdf.spec.js Normal file
View file

@ -0,0 +1,71 @@
// A big PDF (a scanned volume of 400 pages, e2e/make_pdf.py): it opens at once, only the
// pages near the view are drawn, the end can be reached, and opening can be cancelled.
const path = require('path');
const { test, expect } = require('@playwright/test');
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
test.describe.configure({ mode: 'serial' });
const PDF = path.join(__dirname, '.auth', 'big.pdf');
let bookId = null;
let pageErrors = [];
test.beforeEach(({ page }) => {
pageErrors = [];
page.on('pageerror', e => pageErrors.push(String(e)));
});
test.afterEach(() => {
expect(pageErrors, 'errors in the page').toEqual([]);
});
async function ready(page) {
await page.goto('/#books');
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => typeof openBook === 'function' && Array.isArray(_lastBookListData));
}
const drawn = page => page.evaluate(() =>
[...document.querySelectorAll('#reader-content canvas.pdf-page')].filter(c => c.width > 0).length);
test('the big PDF goes in', async ({ page }) => {
await ready(page);
await page.locator('#book-file-input').setInputFiles(PDF);
await page.waitForFunction(() => _lastBookListData.some(b => b.title === 'Dicker Band'), null, { timeout: 30000 });
bookId = await page.evaluate(() => _lastBookListData.find(b => b.title === 'Dicker Band').id);
});
test('it opens with every page placed, only those near the view drawn', async ({ page }) => {
await ready(page);
await page.evaluate(id => openBook(id), bookId);
await page.waitForFunction(() => _readPos && _readPos.ready, null, { timeout: 20000 });
await expect(page.locator('#reader-content .pdf-page-wrapper')).toHaveCount(400);
await expect(page.locator('.pdf-loading-overlay')).toHaveCount(0);
await expect.poll(() => drawn(page)).toBeGreaterThan(0);
expect(await drawn(page)).toBeLessThan(30);
// To the end: the last page gets drawn, the first ones are let go.
await page.evaluate(() => { const c = document.getElementById('reader-content'); c.scrollTop = c.scrollHeight; });
await expect.poll(() => page.evaluate(() => document.querySelector('#pdf-page-400 canvas').width > 0)).toBe(true);
await expect.poll(() => page.evaluate(() => document.querySelector('#pdf-page-1 canvas').width)).toBe(0);
expect(await drawn(page)).toBeLessThan(30);
await page.evaluate(() => closeReader());
});
test('opening can be cancelled, and another book opens after it', async ({ page }) => {
await ready(page);
// Slow the file down so the opening is still under way.
await page.route(`**/books/${bookId}/data/`, async route => {
await new Promise(r => setTimeout(r, 1500));
await route.continue().catch(() => {});
});
const p = page.evaluate(id => openBook(id).then(() => 'done', e => 'error ' + e), bookId);
await expect(page.locator('.reader-opening-cancel')).toBeVisible({ timeout: 5000 });
await page.locator('.reader-opening-cancel').click();
await expect(page.locator('#reader-overlay')).toBeHidden();
expect(await p).toBe('done');
await page.unroute(`**/books/${bookId}/data/`);
// Opening it again works, all the way.
await page.evaluate(id => openBook(id), bookId);
await page.waitForFunction(() => _readPos && _readPos.ready, null, { timeout: 20000 });
await page.evaluate(() => closeReader());
await page.request.post(`/books/${bookId}/delete/`);
});

View file

@ -1846,6 +1846,7 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
animation: pdf-spin 0.7s linear infinite;
}
@keyframes pdf-spin { to { transform: rotate(360deg); } }
.reader-opening { display: flex; flex-direction: column; align-items: center; gap: 16px; }
/* PDF two-page spread */
.pdf-spread-wrapper { display:flex; flex-direction:row; gap:8px; justify-content:center; margin-bottom:1rem; }

View file

@ -22,9 +22,34 @@ async function _parsePdfOutline(pdf, items, depth) {
return result;
}
// The document being loaded (pdf.js loading task), so opening can be cancelled.
let _pdfLoadingTask = null;
// The pages' drawing of the last renderPdf: an observer and its queue (stopped on the next one).
let _pdfLazy = null;
function _pdfStopLazy() {
if (_pdfLazy) {
_pdfLazy.stopped = true;
if (_pdfLazy.observer) _pdfLazy.observer.disconnect();
_pdfLazy = null;
}
}
// Every page gets its place at once (sized like the first page until it is drawn), but only the
// pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages
// opens as fast as one of ten, and doesn't hold 1000 canvases.
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
const myGen = ++_pdfRenderGen;
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise;
_pdfStopLazy();
let pdf = currentPdfDoc;
if (!pdf) {
_pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))});
try {
pdf = await _pdfLoadingTask.promise;
} finally {
_pdfLoadingTask = null;
}
}
if (_pdfRenderGen !== myGen) return null;
currentPdfDoc = pdf;
@ -40,6 +65,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
const outline = await pdf.getOutline();
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
} catch (e) {}
if (_pdfRenderGen !== myGen) return null;
contentEl.innerHTML = '';
@ -52,35 +78,30 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
: Math.min(contentEl.clientWidth - 32, 900);
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const dpr = window.devicePixelRatio || 1;
const scaleFor = vp => (scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
// Pass 1: build DOM structure and size all canvases (no pixel rendering yet)
const pageEntries = [];
// The first page's size stands for all until each is drawn (a scan's pages are alike).
const first = await pdf.getPage(1);
if (_pdfRenderGen !== myGen) return null;
const firstVp = first.getViewport({scale: scaleFor(first.getViewport({scale: 1}))});
const entries = [];
let spreadContainer = null;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100);
const viewport = page.getViewport({scale});
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
wrapper.id = `pdf-page-${pageNum}`;
const inner = document.createElement('div');
inner.className = 'pdf-page-inner';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = Math.round(viewport.width * dpr);
canvas.height = Math.round(viewport.height * dpr);
canvas.style.width = viewport.width + 'px';
canvas.style.height = viewport.height + 'px';
canvas.width = 0;
canvas.height = 0;
canvas.style.width = firstVp.width + 'px';
canvas.style.height = firstVp.height + 'px';
inner.appendChild(canvas);
wrapper.appendChild(inner);
if (!readerSettings.pdfSpread) {
pdfVp.appendChild(wrapper);
} else if (pageNum === 1) {
@ -96,29 +117,85 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
if (spreadContainer) spreadContainer.appendChild(wrapper);
else pdfVp.appendChild(wrapper);
}
pageEntries.push({page, canvas, viewport});
entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null});
}
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);
const lazy = {stopped: false, observer: null, queue: new Set(), working: false};
_pdfLazy = lazy;
const gone = () => lazy.stopped || _pdfRenderGen !== myGen;
for (const idx of order) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const {page, canvas, viewport} = pageEntries[idx];
const ctx = canvas.getContext('2d');
async function draw(e) {
const page = await pdf.getPage(e.num);
if (gone() || !e.near) return;
const viewport = page.getViewport({scale: scaleFor(page.getViewport({scale: 1}))});
e.canvas.style.width = viewport.width + 'px';
e.canvas.style.height = viewport.height + 'px';
e.canvas.width = Math.round(viewport.width * dpr);
e.canvas.height = Math.round(viewport.height * dpr);
const ctx = e.canvas.getContext('2d');
ctx.scale(dpr, dpr);
await page.render({canvasContext: ctx, viewport}).promise;
e.task = page.render({canvasContext: ctx, viewport});
try {
await e.task.promise;
e.drawn = true;
} catch (err) { /* cancelled: let go below */ }
e.task = null;
}
function letGo(e) {
if (e.task) { try { e.task.cancel(); } catch (err) {} }
if (!e.drawn) return;
e.drawn = false;
e.canvas.width = 0; // frees the pixels; the CSS size keeps the page's place
e.canvas.height = 0;
}
// One page at a time, the nearest to the current one first.
async function work() {
if (lazy.working) return;
lazy.working = true;
try {
while (!gone() && lazy.queue.size) {
const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated
? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot);
const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
lazy.queue.delete(next);
if (next.near && !next.drawn) await draw(next);
}
} finally {
lazy.working = false;
}
}
const byEl = new Map(entries.map(e => [e.wrapper, e]));
if (typeof IntersectionObserver === 'function') {
lazy.observer = new IntersectionObserver(items => {
for (const it of items) {
const e = byEl.get(it.target);
if (!e) continue;
e.near = it.isIntersecting;
if (e.near) { if (!e.drawn) lazy.queue.add(e); }
else { lazy.queue.delete(e); letGo(e); }
}
work();
}, {root: contentEl, rootMargin: '150% 0px'});
entries.forEach(e => lazy.observer.observe(e.wrapper));
} else {
// Very old WebViews: every page, from the pivot on (as before).
entries.forEach(e => { e.near = true; lazy.queue.add(e); });
work();
}
// The book shows as soon as the page it opens at is drawn.
const pe = entries[pivot - 1];
pe.near = true;
if (!pe.drawn) await draw(pe);
if (gone()) return null;
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}

View file

@ -47,10 +47,34 @@ function exitReaderImmersiveMode() {
// progress saved from that doubled DOM had block indices beyond the real book,
// which then out-ranked every real position on the server.
let _openBookInFlight = null;
// The opening under way: {cancelled, ctrl} — "Abbrechen", closing the reader or opening another
// book end it (docs: a big scanned PDF used to take minutes and could not be left).
let _openToken = null;
function cancelBookOpening() {
const t = _openToken;
if (!t || t.cancelled) return false;
t.cancelled = true;
try { t.ctrl.abort(); } catch (e) {}
_pdfRenderGen++;
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
if (typeof _pdfLoadingTask !== 'undefined' && _pdfLoadingTask) { try { _pdfLoadingTask.destroy(); } catch (e) {} }
document.querySelectorAll('.pdf-loading-overlay').forEach(el => el.remove());
const overlay = $('reader-overlay');
if (overlay) overlay.style.display = 'none';
const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = '';
currentPdfDoc = null;
currentPdfBuffer = null;
_readPos = null;
return true;
}
window.cancelBookOpening = cancelBookOpening;
async function openBook(bookId) {
if (_openBookInFlight) {
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
cancelBookOpening(); // another book: the one still loading is left
await _openBookInFlight.promise.catch(() => {});
}
const promise = _openBookOnce(bookId);
@ -73,6 +97,16 @@ async function _openBookOnce(bookId) {
titleEl.textContent = 'Lade …';
contentEl.innerHTML = '';
overlay.style.display = '';
const tok = {cancelled: false, ctrl: new AbortController()};
_openToken = tok;
// A moment later (not for a book that opens at once): the spinner with "Abbrechen".
const loadingEl = document.createElement('div');
loadingEl.className = 'pdf-loading-overlay';
loadingEl.innerHTML = '<div class="reader-opening"><span class="pdf-loading-spinner"></span>' +
'<button type="button" class="btn btn-sm reader-opening-cancel">Abbrechen</button></div>';
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
const left = () => tok.cancelled;
try {
loadReaderSettings(bookId);
@ -80,13 +114,15 @@ async function _openBookOnce(bookId) {
const key = await getOrCreateEncKey();
let data_ct, data_iv;
const cached = await _getCachedBook(bookId);
if (left()) return;
if (cached) {
({data_ct, data_iv} = cached);
} else {
let res;
try {
res = await fetch(`/books/${bookId}/data/`);
res = await fetch(`/books/${bookId}/data/`, {signal: tok.ctrl.signal});
} catch {
if (left()) return;
// offline and book not cached yet
overlay.style.display = 'none';
const toast = document.createElement('div');
@ -97,9 +133,11 @@ async function _openBookOnce(bookId) {
return;
}
({data_ct, data_iv} = await res.json());
if (left()) return;
_setCachedBook(bookId, data_ct, data_iv); // fire-and-forget
}
const plain = await decryptBytes(key, data_iv, data_ct);
if (left()) return;
// Revoke any previous image blob URLs
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
@ -114,12 +152,8 @@ async function _openBookOnce(bookId) {
if (isPdfBook) {
currentPdfDoc = null; // reset so renderPdf creates fresh doc
const loadingEl = document.createElement('div');
loadingEl.className = 'pdf-loading-overlay';
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
overlay.appendChild(loadingEl);
const result = await renderPdf(plain, contentEl);
loadingEl.remove();
if (left() || !result) return;
title = result.title || title;
author = result.author || author;
toc = result.toc;
@ -130,6 +164,7 @@ async function _openBookOnce(bookId) {
// Prepared before (same book file, same preparation code): only the images to unpack.
const prepared = await _getParsedBook(bookId, data_iv, key);
const result = prepared ? await _restoreParsedBook(prepared, plain) : await parseEpub(plain);
if (left()) return;
if (!prepared) {
// Kept for the next open once the book is on screen (encrypting takes a moment).
setTimeout(() => _setParsedBook(bookId, data_iv, key, result), 3000);
@ -150,12 +185,19 @@ async function _openBookOnce(bookId) {
} while (i < result.chapters.length && performance.now() - start < 50);
contentEl.appendChild(frag);
if (i < result.chapters.length) await new Promise(r => setTimeout(r, 0));
if (left()) return;
}
// On screen: no more need to offer "Abbrechen".
clearTimeout(showLoading);
loadingEl.remove();
// Used to decide whole-percent vs one-decimal display further down —
// see _formatBookPercent().
_currentBookCharCount = contentEl.textContent.length;
}
clearTimeout(showLoading);
loadingEl.remove();
if (_openToken === tok) _openToken = null;
currentBookToc = toc;
titleEl.textContent = title + (author ? ` — ${author}` : '');
@ -364,9 +406,14 @@ async function _openBookOnce(bookId) {
if (typeof _issueReaderOpened === 'function') _issueReaderOpened(bookId, isPdf);
} catch (e) {
if (left()) return; // cancelled: the half-loaded document may complain, that's all
console.error('openBook failed:', e);
overlay.style.display = 'none';
markBookBroken(bookId);
} finally {
clearTimeout(showLoading);
loadingEl.remove();
if (_openToken === tok) _openToken = null;
}
}
@ -499,6 +546,8 @@ async function saveReaderProgress(force = false) {
}
function closeReader() {
// Still opening: just leave it (nothing to save yet).
if (cancelBookOpening()) return;
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
exitReaderImmersiveMode();
stopReadAloud();
@ -564,6 +613,8 @@ function closeReader() {
currentBookId = null;
currentBookToc = [];
_currentBookCharCount = 0;
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null;
currentPdfBuffer = null;
currentBookmarks = [];

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v131';
const CACHE = 'diora-v132';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',