'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}; }