diora-web/static/js/app/58-pdf.js
mrwnslz c2a9873b0d PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
  abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
  gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
  (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
  verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
  ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
  Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
  Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
  vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
  nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
  Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
  gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
  Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
  immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
  wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
  sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
  ein zweites Buch geben das alte Dokument frei; der entschlüsselte
  PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
  Outline, Links und einer fast leeren Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00

304 lines
12 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// The PDF reader: one document, shown either scrolling (every page has its
// place, only those near the view are drawn) or page by page, cropped to the
// page's text (66-pdf-paginated.js). Whichever, a page is drawn only through
// _pdfDraw: one drawing at a time per canvas, sharp at the device's pixel
// ratio but never larger than PDF_MAX_PIXELS.
// ---------------------------------------------------------------------------
// pdf.js's own viewer caps a canvas at this many pixels: beyond it phones run out of memory.
const PDF_MAX_PIXELS = 16777216;
// One pdf.js worker for every document (the reader, covers, uploads): starting one takes a
// moment — on an e-reader a long one.
let _pdfWorker = null;
function _pdfSharedWorker() {
if (!_pdfWorker && typeof pdfjsLib !== 'undefined' && pdfjsLib.PDFWorker) {
try { _pdfWorker = new pdfjsLib.PDFWorker(); } catch (e) { _pdfWorker = null; }
}
return _pdfWorker;
}
/** pdf.js's loading task for [bytes] (a Uint8Array), on the shared worker. */
function _pdfLoad(bytes) {
const worker = _pdfSharedWorker();
return pdfjsLib.getDocument(worker ? {data: bytes, worker} : {data: bytes});
}
// The table of contents: all entries resolved at once (one after the other cost a round trip
// to the worker each). A destination's page is a reference or, in some files, a plain index.
async function _parsePdfOutline(pdf, items, depth) {
depth = depth || 0;
const parts = await Promise.all(items.map(async item => {
let href = '';
if (item.dest) {
try {
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
if (dest && dest[0] != null) {
const index = typeof dest[0] === 'number' ? dest[0] : await pdf.getPageIndex(dest[0]);
href = `#pdf-page-${index + 1}`;
}
} catch (e) {}
}
const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : [];
return [{label: item.title || '(untitled)', href, depth}].concat(kids);
}));
return [].concat.apply([], parts);
}
// The document being loaded (pdf.js loading task), so opening can be cancelled.
let _pdfLoadingTask = null;
// The open document's pages and how they are drawn (renderPdf; stopped on the next one).
let _pdfView = null;
// Backwards name: the reader stops "the lazy drawing" when a book closes.
let _pdfLazy = null;
function _pdfStopLazy() {
const v = _pdfView;
if (!v) return;
v.stopped = true;
if (v.observer) v.observer.disconnect();
if (v.resize) v.resize.disconnect();
clearTimeout(v.resizeTimer);
v.entries.forEach(e => { e.token++; if (e.task) { try { e.task.cancel(); } catch (err) {} } });
_pdfView = _pdfLazy = null;
}
/**
* Draw page [page] of entry [e] at [cssScale] (CSS pixels per PDF point). Cancels a drawing
* of the same canvas still under way (pdf.js refuses two at once) and waits for it to let go.
* Returns false when a newer drawing (or letting go) came first.
*/
async function _pdfDraw(e, page, cssScale) {
const token = ++e.token;
const prev = e.task;
if (prev) {
try { prev.cancel(); } catch (err) {}
try { await prev.promise; } catch (err) {}
if (e.token !== token) return false;
}
const viewport = page.getViewport({scale: cssScale});
let ratio = window.devicePixelRatio || 1;
const area = viewport.width * viewport.height;
if (area * ratio * ratio > PDF_MAX_PIXELS) ratio = Math.max(0.5, Math.sqrt(PDF_MAX_PIXELS / area));
e.canvas.width = Math.round(viewport.width * ratio);
e.canvas.height = Math.round(viewport.height * ratio);
e.canvas.style.width = viewport.width + 'px';
e.canvas.style.height = viewport.height + 'px';
const task = page.render({
canvasContext: e.canvas.getContext('2d'),
viewport,
transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : null,
});
e.task = task;
try {
await task.promise;
} catch (err) {
if (e.task === task) e.task = null;
if (err && err.name !== 'RenderingCancelledException') console.warn('PDF page', e.num, err);
return false;
}
if (e.task === task) e.task = null;
if (e.token !== token) return false;
e.drawn = cssScale;
return true;
}
/** Let a page's pixels go (its place stays) and pdf.js forget what it parsed of it. */
function _pdfLetGo(e, pdf) {
e.token++;
if (e.task) { try { e.task.cancel(); } catch (err) {} }
e.drawn = 0;
e.canvas.width = 0;
e.canvas.height = 0;
if (pdf) pdf.getPage(e.num).then(p => { if (!e.task) p.cleanup(); }, () => {});
}
/** The page shown at the top of the scrolling view (binary search, the pages are in order). */
function _pdfTopPage(contentEl) {
const v = _pdfView;
if (!v || !v.entries.length) return pdfCurrentPage || 1;
const top = contentEl.getBoundingClientRect().top + 20;
let lo = 0, hi = v.entries.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (v.entries[mid].wrapper.getBoundingClientRect().bottom > top) hi = mid; else lo = mid + 1;
}
return v.entries[lo].num;
}
// 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. Page by page (pdfPaginated),
// 66-pdf-paginated.js shows and draws the pages itself.
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
const myGen = ++_pdfRenderGen;
_pdfStopLazy();
let pdf = currentPdfDoc;
if (!pdf) {
_pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0)));
try {
pdf = await _pdfLoadingTask.promise;
} finally {
_pdfLoadingTask = null;
}
}
if (_pdfRenderGen !== myGen) return null;
currentPdfDoc = pdf;
let pdfTitle = '', pdfAuthor = '';
try {
const meta = await pdf.getMetadata();
pdfTitle = meta.info?.Title?.trim() || '';
pdfAuthor = meta.info?.Author?.trim() || '';
} catch (e) {}
let toc = [];
try {
const outline = await pdf.getOutline();
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
} catch (e) {}
if (_pdfRenderGen !== myGen) return null;
contentEl.innerHTML = '';
const pdfVp = document.createElement('div');
pdfVp.id = 'pdf-viewport';
contentEl.appendChild(pdfVp);
const containerWidth = readerSettings.pdfSpread
? contentEl.clientWidth - 32
: Math.min(contentEl.clientWidth - 32, 900);
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const scaleFor = vp => (scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
// 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++) {
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 = 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 || readerSettings.pdfPaginated) {
pdfVp.appendChild(wrapper);
} else if (pageNum === 1) {
wrapper.classList.add('pdf-spread-cover');
pdfVp.appendChild(wrapper);
spreadContainer = null;
} else {
if (pageNum % 2 === 0) {
spreadContainer = document.createElement('div');
spreadContainer.className = 'pdf-spread-wrapper';
pdfVp.appendChild(spreadContainer);
}
if (spreadContainer) spreadContainer.appendChild(wrapper);
else pdfVp.appendChild(wrapper);
}
entries.push({num: pageNum, wrapper, inner, canvas, drawn: 0, near: false, task: null, token: 0});
}
pdfTotalPages = pdf.numPages;
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null,
resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0};
_pdfView = _pdfLazy = view;
const gone = () => view.stopped || _pdfRenderGen !== myGen;
async function draw(e) {
const page = await pdf.getPage(e.num);
if (gone() || !e.near || readerSettings.pdfPaginated) return;
await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1})));
}
// One page at a time, the nearest to the current one first.
async function work() {
if (view.working) return;
view.working = true;
try {
while (!gone() && view.queue.size && !readerSettings.pdfPaginated) {
const cur = _pdfTopPage(contentEl);
const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
view.queue.delete(next);
if (next.near && !next.drawn) await draw(next);
}
} finally {
view.working = false;
}
}
// Turning the device, a narrower window, the bars coming or going: laid out again at the same page.
let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight;
if (typeof ResizeObserver === 'function') {
view.resize = new ResizeObserver(() => {
const w = contentEl.clientWidth, h = contentEl.clientHeight;
if (!w || !h || (w === lastW && h === lastH)) return;
const widthChanged = Math.abs(w - lastW) > 2;
lastW = w;
lastH = h;
clearTimeout(view.resizeTimer);
view.resizeTimer = setTimeout(() => {
if (gone()) return;
if (readerSettings.pdfPaginated) _pdfShowPage(pdfCurrentPage);
else if (widthChanged) reRenderPdf(_pdfTopPage(contentEl));
}, readerSettings.pdfPaginated ? 150 : 300);
});
view.resize.observe(contentEl);
}
if (readerSettings.pdfPaginated) {
// Page by page: the pivot page cropped to its text, at once (66-pdf-paginated.js).
pdfCurrentPage = pivot;
await _pdfShowPage(pivot);
if (gone()) return null;
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}
if (pivot > 1) entries[pivot - 1].wrapper.scrollIntoView({block: 'start'});
pdfCurrentPage = pivot;
const byEl = new Map(entries.map(e => [e.wrapper, e]));
if (typeof IntersectionObserver === 'function') {
view.observer = new IntersectionObserver(items => {
if (readerSettings.pdfPaginated) return;
for (const it of items) {
const e = byEl.get(it.target);
if (!e) continue;
e.near = it.isIntersecting;
if (e.near) { if (!e.drawn) view.queue.add(e); }
else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); }
}
work();
}, {root: contentEl, rootMargin: '150% 0px'});
entries.forEach(e => view.observer.observe(e.wrapper));
} else {
// Very old WebViews: every page, from the pivot on (as before).
entries.forEach(e => { e.near = true; view.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};
}