diff --git a/e2e/global-setup.js b/e2e/global-setup.js index 581f302..ab42a65 100644 --- a/e2e/global-setup.js +++ b/e2e/global-setup.js @@ -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). diff --git a/e2e/make_pdf.py b/e2e/make_pdf.py new file mode 100644 index 0000000..d3760f4 --- /dev/null +++ b/e2e/make_pdf.py @@ -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) diff --git a/e2e/pdf.spec.js b/e2e/pdf.spec.js new file mode 100644 index 0000000..4befea6 --- /dev/null +++ b/e2e/pdf.spec.js @@ -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/`); +}); diff --git a/static/css/app.css b/static/css/app.css index 4fd1024..94c2eb8 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js index 61138d9..375f688 100644 --- a/static/js/app/58-pdf.js +++ b/static/js/app/58-pdf.js @@ -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}; } diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js index d352e8d..453fafe 100644 --- a/static/js/app/62-reader.js +++ b/static/js/app/62-reader.js @@ -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 = '