'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; // Zoom (readerSettings.pdfZoom, %): the PDF panel's slider and pinching. const PDF_ZOOM_MIN = 50; const PDF_ZOOM_MAX = 200; let _pdfTurnSaveTimer = 0; let _pdfWheelSum = 0; let _pdfWheelAt = 0; let _touchStartY = 0; // A touch gesture under way: {kind: 'pinch' | 'pan' | 'after'} (_pdfTouchStart). let _pdfGesture = null; let _pdfPanAt = 0; // when a page was last moved by a finger (that touch is no tap) 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.transform = ''; pdfVp.style.transformOrigin = ''; } // 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; // The page was just moved with a finger: no tap. if (Date.now() - _pdfPanAt < 400) 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(); // A page taller than the screen (zoomed in) is moved first; at its end the wheel turns. const pe = _pdfView && _pdfView.entries[pdfCurrentPage - 1]; if (pe && pe.place && pe.place.minY < pe.place.maxY) { const y = Math.max(pe.place.minY, Math.min(pe.place.maxY, pe.pos.y - e.deltaY)); if (Math.abs(y - pe.pos.y) > 0.5) { _pdfPlace(pe, pe.pos.x, y); _pdfKeepFocus(pe); _pdfWheelSum = 0; _pdfWheelAt = Date.now(); return; } } 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); } // Touch (62-reader.js listens for every book; only a PDF's are handled here): // - two fingers zoom around the point between them: a preview while they move (a CSS // transform, the layout stays as it is), then drawn anew at the new size with that point // where it was (scrolling: _pdfRezoomScroll; page by page: _pdfFitPage and v.focus); // - page by page, a page larger than the screen moves with one finger, and pushed on past // its edge sideways it turns; otherwise a swipe turns. function _pdfTouchStart(e) { const v = _pdfView; if (!currentPdfDoc || !v) return; const contentEl = $('reader-content'); if (e.touches.length === 2) { const a = e.touches[0], b = e.touches[1]; const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2; const r = contentEl.getBoundingClientRect(); const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top}; if (readerSettings.pdfPaginated) { g.entry = v.entries[pdfCurrentPage - 1]; if (!g.entry || !g.entry.pos) return; g.pos = {x: g.entry.pos.x, y: g.entry.pos.y}; } else { const vp = document.getElementById('pdf-viewport'); if (!vp) return; const vr = vp.getBoundingClientRect(); g.vp = vp; g.ox = x - vr.left; g.oy = y - vr.top; g.anchor = _pdfPointAt(x, y); } _pdfGesture = g; contentEl.classList.add('pinch-active'); return; } if (e.touches.length !== 1) return; const t = e.touches[0]; _touchStartX = t.clientX; _touchStartY = t.clientY; _pdfGesture = null; const pe = readerSettings.pdfPaginated && v.entries[pdfCurrentPage - 1]; if (pe && pe.place && (pe.place.minX < pe.place.maxX || pe.place.minY < pe.place.maxY)) { _pdfGesture = {kind: 'pan', entry: pe, x: t.clientX, y: t.clientY, ox: pe.pos.x, oy: pe.pos.y, dx: 0, dy: 0, moved: false}; } } function _pdfTouchMove(e) { const g = _pdfGesture; if (!g) return; if (g.kind === 'pinch') { if (e.touches.length !== 2) return; e.preventDefault(); const a = e.touches[0], b = e.touches[1]; const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist)); g.f = zoom / g.zoom; if (g.entry) { // The page grows around the point between the fingers (its transform's origin is 0 0). const x = g.cx - g.f * (g.cx - g.pos.x), y = g.cy - g.f * (g.cy - g.pos.y); g.entry.inner.style.transform = `translate(${x}px, ${y}px) scale(${g.f})`; } else { g.vp.style.transformOrigin = `${g.ox}px ${g.oy}px`; g.vp.style.transform = `scale(${g.f})`; } return; } if (g.kind === 'pan' && e.touches.length === 1) { const t = e.touches[0]; g.dx = t.clientX - g.x; g.dy = t.clientY - g.y; if (!g.moved && Math.hypot(g.dx, g.dy) < 8) return; g.moved = true; e.preventDefault(); _pdfPlace(g.entry, g.ox + g.dx, g.oy + g.dy); } } function _pdfTouchEnd(e) { const g = _pdfGesture; if (g && g.kind === 'pinch') { // The other finger may stay down a moment: it's no swipe. _pdfGesture = {kind: 'after'}; const contentEl = $('reader-content'); if (contentEl) contentEl.classList.remove('pinch-active'); const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10)); if (g.entry) { const pe = g.entry, s = pe.drawn, p = pe.place; // The page's point between the fingers stays there (_pdfFitPage); the preview stays // until the page is drawn at its new size. if (s && p) { _pdfView.focus = {x: (g.cx - g.pos.x) / s, y: (g.cy - g.pos.y) / s, sx: g.cx / p.W, sy: g.cy / p.H}; _pdfView.focusPage = pe.num; } if (zoom === readerSettings.pdfZoom) _pdfPlace(pe, g.pos.x, g.pos.y); } else { g.vp.style.transform = ''; g.vp.style.transformOrigin = ''; } if (zoom !== readerSettings.pdfZoom) applyPdfZoom(zoom, g.anchor); return; } if (g && g.kind === 'after') { if (!e.touches || !e.touches.length) _pdfGesture = null; return; } if (g && g.kind === 'pan') { _pdfGesture = null; if (!g.moved) return; // a tap: the click turns or shows the bars _pdfPanAt = Date.now(); _pdfKeepFocus(g.entry); // Already at its edge and pushed on sideways: the next or previous page (a move that only // reaches the edge doesn't turn as well). const p = g.entry.place; const atEdge = g.dx < 0 ? g.ox <= p.minX + 1 : g.ox >= p.maxX - 1; if (atEdge && Math.abs(g.dx) > 50 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5) pdfGoToPage(pdfCurrentPage + (g.dx > 0 ? -1 : 1)); return; } if (!currentPdfDoc || !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; } // The text box centred where it fits (at the scale it was drawn with); larger than the // screen (zoomed in), it can be moved up to its edges and starts at its top left — or // where it was looked at (v.focus: a pinch, a move, the screen turned). const s = e.drawn; const range = (size, lo, hi) => { if (hi - lo + PDF_CROP_PAD * 2 <= size + 1) { const c = (size - (hi - lo)) / 2 - lo; return [c, c]; } return [size - PDF_CROP_PAD - hi, PDF_CROP_PAD - lo]; }; const [minX, maxX] = range(W, s * box.x1, s * box.x2); const [minY, maxY] = range(H, s * box.y1, s * box.y2); e.place = {minX, maxX, minY, maxY, W, H}; const f = v.focusPage === e.num ? v.focus : null; e.inner.style.transformOrigin = '0 0'; _pdfPlace(e, f ? W * f.sx - s * f.x : maxX, f ? H * f.sy - s * f.y : maxY); 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 = ''; } /** Page [e] moved to (x, y) (its offset in the reading area), kept within its edges (e.place). */ function _pdfPlace(e, x, y) { const p = e.place; if (p) { x = Math.max(p.minX, Math.min(p.maxX, x)); y = Math.max(p.minY, Math.min(p.maxY, y)); } e.pos = {x, y}; e.inner.style.transform = `translate(${x}px, ${y}px)`; } /** Page [e] moved: that part of it stays in view when it's laid out again (another zoom, turned). */ function _pdfKeepFocus(e) { const v = _pdfView, p = e.place; if (!v || !p || !e.drawn) return; v.focus = {x: (p.W / 2 - e.pos.x) / e.drawn, y: (p.H / 2 - e.pos.y) / e.drawn, sx: 0.5, sy: 0.5}; v.focusPage = e.num; } /** * 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}; }