diora-web/static/js/app/66-pdf-paginated.js
mrwnslz 429b875faf
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m30s
Test / e2e (push) Failing after 1m30s
PDF: öffnet an der gespeicherten Seite, Leserfarben, Seitenzahlen wie im Buch (sw CACHE v166)
Der Lesestand wird gleich beim Öffnen erfragt (parallel zur Datei), ein PDF zeichnet zuerst
die Seite, an der es weitergeht, statt Seite 1; Titel, Inhaltsverzeichnis und Seitenzahlen
kommen nebenher und halten das Öffnen nicht mehr auf. Eine Zeichnung, die schon läuft, wird
abgewartet statt neu begonnen.

„Leserfarben“ (neben Originalfarben und Invertieren): die Seite in den Farben des Lesers
(Dunkel, Sepia) per SVG-Filter, Bilder darüber wie gedruckt (ihre Lage aus den
Zeichenbefehlen); ein Bild, das die halbe Seite füllt, ist ein Scan und wird mit umgefärbt.
pdf.js' eigene pageColors färben die Bilder mit um. Ein anderes Design ändert nur die Farben
des Filters.

Seitenzahlen des Buchs (PageLabels: „iv“, „A-3“) im Kopf mit der Seite der Datei daneben,
eingeben lässt sich beides; Lesezeichen und der Zurück-Knopf nennen sie. Das Test-PDF hat
dafür Seitenzahlen und ein farbiges Bild bekommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:42:26 +01:00

294 lines
12 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel):
// one page at a time, cropped to its text so the letters are as large as the
// screen allows, the next one drawn ahead for the turn. A tap on the left or
// right turns (the middle shows the bars), so do a swipe, the arrow keys,
// Space, PageUp/PageDown and the mouse wheel. Every page goes through
// _pdfDraw (58-pdf.js).
// ---------------------------------------------------------------------------
// A page nearly empty (only its number) would be zoomed to fill the screen with one digit:
// at most this many times the size the whole page would have.
const PDF_CROP_MAX = 2.5;
const PDF_CROP_PAD = 12;
let _pdfTurnSaveTimer = 0;
let _pdfWheelSum = 0;
let _pdfWheelAt = 0;
let _touchStartY = 0;
function enterPdfPaginatedMode() {
const contentEl = $('reader-content');
if (!contentEl) return;
contentEl.classList.add('pdf-paginated');
contentEl.style.overflow = 'hidden';
const pdfVp = document.getElementById('pdf-viewport');
if (pdfVp) pdfVp.style.zoom = '';
// From scrolling: the drawing near the view stops, the pages are this mode's now.
const v = _pdfView;
if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); }
contentEl.addEventListener('click', _pdfPaginatedClick);
contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false});
_pdfShowPage(pdfCurrentPage);
}
/** Back to scrolling, at [page] (the one shown now). */
function exitPdfPaginatedMode(page) {
const contentEl = $('reader-content');
if (!contentEl) return;
contentEl.classList.remove('pdf-paginated');
contentEl.style.overflow = '';
contentEl.removeEventListener('click', _pdfPaginatedClick);
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
reRenderPdf(page || pdfCurrentPage);
}
function _pdfPaginatedClick(e) {
if (e.target.closest('a, button, input')) return;
// Text just selected (the text layer): that tap wasn't a turn.
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
const r = e.currentTarget.getBoundingClientRect();
const x = e.clientX - r.left;
const dir = x < r.width * 0.4 ? -1 : x > r.width * 0.6 ? 1 : 0;
if (!dir) return; // the middle: the bars come or go (62-reader.js)
// A turn is a turn only: not also the bars.
e.stopPropagation();
pdfGoToPage(pdfCurrentPage + dir);
}
function _pdfPaginatedWheel(e) {
if (e.ctrlKey) return; // the browser's zoom
e.preventDefault();
const now = Date.now();
if (now - _pdfWheelAt > 400) _pdfWheelSum = 0;
_pdfWheelAt = now;
_pdfWheelSum += e.deltaY;
if (Math.abs(_pdfWheelSum) < 60) return;
const dir = _pdfWheelSum > 0 ? 1 : -1;
_pdfWheelSum = 0;
_pdfWheelAt = now + 250; // one turn per flick of the wheel
pdfGoToPage(pdfCurrentPage + dir);
}
function _pdfTouchStart(e) {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_pinchStartDist = Math.hypot(dx, dy);
_pinchStartZoom = readerSettings.pdfZoom;
_isPinching = true;
const ce = $('reader-content');
if (ce) ce.classList.add('pinch-active');
} else {
_touchStartX = e.touches[0].clientX;
_touchStartY = e.touches[0].clientY;
_isPinching = false;
}
}
function _pdfTouchMove(e) {
if (e.touches.length !== 2 || !_isPinching) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
if (_pinchStartDist === 0) return;
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
const vp = document.getElementById('pdf-viewport');
if (vp) vp.style.zoom = liveZoom / 100;
}
function _pdfTouchEnd(e) {
if (_isPinching) {
_isPinching = false;
const ce = $('reader-content');
if (ce) ce.classList.remove('pinch-active');
const vp = document.getElementById('pdf-viewport');
const liveZoom = vp && vp.style.zoom ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
// The live zoom was only a preview: the page is drawn anew at the new size.
if (vp) vp.style.zoom = '';
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
applyPdfZoom(snapped);
return;
}
if (!readerSettings.pdfPaginated || !e.changedTouches.length) return;
const dx = e.changedTouches[0].clientX - _touchStartX;
const dy = e.changedTouches[0].clientY - _touchStartY;
// Sideways, clearly: a slanted scroll is not a turn.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
// Dragging a selection's handles (the text layer) isn't a swipe.
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
pdfGoToPage(pdfCurrentPage + (dx > 0 ? -1 : 1));
}
/** The page number the reader shows (header input) follows the page. */
function _pdfSetCurrentPage(n) {
pdfCurrentPage = n;
const input = $('reader-progress-input');
if (input && currentPdfDoc && document.activeElement !== input) input.value = pdfPageLabel(n);
// With the book's own page numbers: which page of the file it is beside them.
const suffix = $('reader-progress-suffix');
if (suffix && currentPdfDoc && pdfTotalPages) {
suffix.textContent = _pdfLabels ? `(${n} / ${pdfTotalPages})` : `/ ${pdfTotalPages}`;
if (input) input.setAttribute('inputmode', _pdfLabels ? 'text' : 'decimal');
}
}
/**
* Go to page [n], scrolling or page by page: the table of contents, the page
* number in the header, bookmarks, keys and the app's "Zu Seite springen".
*/
function pdfGoToPage(n) {
if (!currentPdfDoc) return;
n = Math.max(1, Math.min(pdfTotalPages, n | 0));
const contentEl = $('reader-content');
if (!contentEl) return;
if (!readerSettings.pdfPaginated) {
const target = contentEl.querySelector(`#pdf-page-${n}`);
if (target) {
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8;
}
_pdfSetCurrentPage(n);
return;
}
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
_pdfShowPage(n);
_pdfTurned();
}
// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers
// get their refresh (native-eink.js listens).
function _pdfTurned() {
if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction();
clearTimeout(_pdfTurnSaveTimer);
_pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500);
try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {}
}
/** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */
async function _pdfShowPage(n) {
const v = _pdfView;
if (!v || !v.entries.length) return;
n = Math.max(1, Math.min(v.entries.length, n));
_pdfSetCurrentPage(n);
v.shown = n;
const token = ++v.showToken;
v.entries.forEach(e => { e.wrapper.style.display = e.num === n ? '' : 'none'; });
try {
await _pdfFitPage(v, v.entries[n - 1]);
if (token !== v.showToken || v.stopped) return;
if (n < v.entries.length) await _pdfFitPage(v, v.entries[n], true);
if (token !== v.showToken || v.stopped) return;
if (n > 1 && !v.entries[n - 2].drawn) await _pdfFitPage(v, v.entries[n - 2], true);
} catch (err) {
console.warn('PDF page', n, err);
}
if (token !== v.showToken || v.stopped) return;
v.entries.forEach(e => { if (Math.abs(e.num - n) > 2 && (e.drawn || e.task)) _pdfLetGo(e, v.pdf); });
}
/**
* Page [e] sized to the screen by its text (its ink, for scans): drawn when the size differs,
* then placed so the text is centred. [ahead]: for the next turn, not shown yet.
*/
async function _pdfFitPage(v, e, ahead) {
const contentEl = v.contentEl;
const page = await v.pdf.getPage(e.num);
if (v.stopped) return;
const natural = page.getViewport({scale: 1});
let box = _pdfPageTextBoxCache[e.num];
if (!box) {
box = await _computePdfTextBox(page, natural);
_pdfPageTextBoxCache[e.num] = box;
}
if (v.stopped) return;
const W = contentEl.clientWidth;
const H = contentEl.clientHeight;
const bw = Math.max(1, box.x2 - box.x1);
const bh = Math.max(1, box.y2 - box.y1);
const fitBox = Math.min((W - PDF_CROP_PAD * 2) / bw, (H - PDF_CROP_PAD * 2) / bh);
const fitPage = Math.min((W - PDF_CROP_PAD * 2) / natural.width, (H - PDF_CROP_PAD * 2) / natural.height);
const scale = Math.max(0.1, Math.min(fitBox, fitPage * PDF_CROP_MAX)) * (readerSettings.pdfZoom / 100);
if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) {
if (!await _pdfDraw(e, page, scale)) return;
}
// Centre the text box (at the scale it was drawn with).
const s = e.drawn;
const offsetX = (W - s * bw) / 2 - s * box.x1;
const offsetY = (H - s * bh) / 2 - s * box.y1;
e.inner.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
e.inner.style.transformOrigin = '0 0';
e.wrapper.style.overflow = 'hidden';
e.wrapper.style.width = W + 'px';
e.wrapper.style.height = H + 'px';
if (!ahead && v.shown === e.num) e.wrapper.style.display = '';
}
/**
* Where the page's content is, in the page's own top-down coordinates at scale 1 (as
* getViewport gives them, so turned pages and odd media boxes come out right): its text,
* or — a scan without text — what isn't white on a small drawing of it.
*/
async function _computePdfTextBox(page, natural) {
const pageW = natural.width, pageH = natural.height;
// Tier 1: text-based
try {
const tc = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent();
if (tc.items && tc.items.length) {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const item of tc.items) {
if (!item.transform || !item.str || !item.str.trim()) continue;
const tx = item.transform[4], ty = item.transform[5];
const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0;
for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) {
const [vx, vy] = natural.convertToViewportPoint(px, py);
if (vx < x1) x1 = vx;
if (vx > x2) x2 = vx;
if (vy < y1) y1 = vy;
if (vy > y2) y2 = vy;
}
}
const area = (x2 - x1) * (y2 - y1);
if (isFinite(x1) && area > pageW * pageH * 0.25) {
return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)};
}
}
} catch (e) {}
// Tier 2: pixel analysis at scale 0.3
try {
const lowScale = 0.3;
const vp = page.getViewport({scale: lowScale});
const offCanvas = document.createElement('canvas');
offCanvas.width = Math.ceil(vp.width);
offCanvas.height = Math.ceil(vp.height);
const ctx = offCanvas.getContext('2d');
await page.render({canvasContext: ctx, viewport: vp}).promise;
const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height);
offCanvas.width = offCanvas.height = 0;
let rMin = height, rMax = 0, cMin = width, cMax = 0;
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
const idx = (r * width + c) * 4;
if (data[idx] + data[idx + 1] + data[idx + 2] < 720) {
if (r < rMin) rMin = r;
if (r > rMax) rMax = r;
if (c < cMin) cMin = c;
if (c > cMax) cMax = c;
}
}
}
if (rMin < rMax && cMin < cMax) {
return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale};
}
} catch (e) {}
// Fallback: full page
return {x1: 0, y1: 0, x2: pageW, y2: pageH};
}