diff --git a/static/css/app.css b/static/css/app.css index dc1571d..b882aa5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1805,11 +1805,17 @@ body.dnd-mode .timer-display { .pdf-page-inner { position:relative; display:inline-block; line-height:0; } /* PDF text layer — sits flush over the canvas inside pdf-page-inner */ -.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; } -.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; } -.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); } -.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); } -.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); } +.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; line-height:1; -webkit-text-size-adjust:none; text-size-adjust:none; z-index:1; } +.pdf-text-layer span, .pdf-text-layer br { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; transform-origin:0 0; } +.pdf-text-layer span::selection, .pdf-text-layer mark::selection { background:rgba(0,120,255,0.3); } +.pdf-text-layer br::selection { background:transparent; } +.pdf-text-layer mark.reader-search-match { color:transparent; background:rgba(241,196,15,.5); padding:0; border-radius:2px; } +.pdf-text-layer mark.reader-search-match.active { background:rgba(230,57,70,.6); } +/* PDF links — over the text, so a tap on one follows it */ +.pdf-link-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:2; } +.pdf-link { position:absolute; display:block; pointer-events:auto; border-radius:2px; } +.pdf-link:hover, .pdf-link:focus-visible { background:rgba(0,120,255,0.15); outline:none; } +.pdf-back-btn { position:absolute; left:50%; bottom:calc(var(--bar-h, 0px) + 24px); transform:translateX(-50%); z-index:20; box-shadow:0 2px 8px rgba(0,0,0,.25); } /* PDF invert */ /* Dark pages: inverted, and the hues turned back, so photos and coloured figures aren't negatives. */ diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js index 78cd183..cdfee60 100644 --- a/static/js/app/58-pdf.js +++ b/static/js/app/58-pdf.js @@ -28,20 +28,29 @@ function _pdfLoad(bytes) { return pdfjsLib.getDocument(worker ? {data: bytes, worker} : {data: bytes}); } +/** + * The page (from 1) a destination leads to — a named one or an explicit array, whose page is + * a reference or, in some files, a plain index. 0 when it leads nowhere. + */ +async function _pdfDestPage(pdf, dest) { + try { + if (typeof dest === 'string') dest = await pdf.getDestination(dest); + if (!dest || dest[0] == null) return 0; + return (typeof dest[0] === 'number' ? dest[0] : await pdf.getPageIndex(dest[0])) + 1; + } catch (e) { + return 0; + } +} + // 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. +// to the worker each). 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 n = await _pdfDestPage(pdf, item.dest); + if (n) href = `#pdf-page-${n}`; } const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : []; return [{label: item.title || '(untitled)', href, depth}].concat(kids); @@ -56,6 +65,163 @@ let _pdfView = null; // Backwards name: the reader stops "the lazy drawing" when a book closes. let _pdfLazy = null; +// The open document's text, page by page (pdf.js text content): for the text layer, the +// search and the crop to the text box. Kept for the document, not for one layout of it. +let _pdfTexts = null; + +function _pdfPageText(pdf, num) { + if (!_pdfTexts || _pdfTexts.pdf !== pdf) _pdfTexts = {pdf, pages: new Map()}; + let p = _pdfTexts.pages.get(num); + if (!p) { + p = pdf.getPage(num).then(page => page.getTextContent()).catch(() => ({items: [], styles: {}})); + _pdfTexts.pages.set(num, p); + } + return p; +} + +/** + * A page's text as one string, lower case, for searching — with where each item (each span of + * the text layer, in order) starts in it. Lines ending inside it get a space. + */ +function _pdfPageString(tc) { + if (tc._diora) return tc._diora; + let str = ''; + const starts = []; + for (const item of tc.items) { + starts.push(str.length); + str += item.str || ''; + if (item.hasEOL) str += ' '; + } + tc._diora = {str: str.toLowerCase(), starts}; + return tc._diora; +} + +// Internal links followed, the pages they were followed from (the back button). +let _pdfBackStack = []; + +/** The document is gone (another one, or the reader closed): what belonged to it goes too. */ +function _pdfForgetDoc() { + _pdfTexts = null; + _pdfBackStack = []; + _pdfShowBack(); + if (typeof _pdfSearchStop === 'function') _pdfSearchStop(); +} + +async function _pdfFollowDest(dest) { + const pdf = currentPdfDoc; + if (!pdf) return; + const n = await _pdfDestPage(pdf, dest); + if (!n || pdf !== currentPdfDoc) return; + const contentEl = $('reader-content'); + const from = readerSettings.pdfPaginated || !contentEl ? pdfCurrentPage : _pdfTopPage(contentEl); + if (n === from) return; + _pdfBackStack.push(from); + pdfGoToPage(n); + _pdfShowBack(); +} + +/** The button back to where the last link was followed from (gone when there's none). */ +function _pdfShowBack() { + let btn = document.getElementById('pdf-back-btn'); + const from = _pdfBackStack[_pdfBackStack.length - 1]; + if (!from) { + if (btn) btn.remove(); + return; + } + if (!btn) { + const overlay = $('reader-overlay'); + if (!overlay) return; + btn = document.createElement('button'); + btn.id = 'pdf-back-btn'; + btn.type = 'button'; + btn.className = 'btn btn-sm pdf-back-btn'; + btn.addEventListener('click', ev => { + ev.stopPropagation(); + const page = _pdfBackStack.pop(); + if (page) pdfGoToPage(page); + _pdfShowBack(); + }); + overlay.appendChild(btn); + } + btn.textContent = `← Zurück zu S. ${from}`; +} + +/** A page's text layer and links taken off (another scale, or the page let go). */ +function _pdfDropLayers(e) { + if (e.textTask) { try { e.textTask.cancel(); } catch (err) {} } + e.textTask = null; + e.textDivs = null; + e.textInfo = null; + e.marked = null; + e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer').forEach(el => el.remove()); +} + +/** + * Over a drawn page: its text (transparent, to select and copy, and where the search marks + * go) and its links. After the pixels, so a turn isn't held up by them. + */ +async function _pdfLayers(e, page, viewport, token) { + const pdf = currentPdfDoc; + if (!pdf) return; + let annots = []; + try { annots = await page.getAnnotations({intent: 'display'}); } catch (err) {} + if (e.token !== token) return; + _pdfLinks(e, viewport, annots); + if (typeof pdfjsLib.renderTextLayer !== 'function') return; + const text = await _pdfPageText(pdf, e.num); + if (e.token !== token || !text.items.length) return; + const layer = document.createElement('div'); + layer.className = 'pdf-text-layer'; + layer.style.setProperty('--scale-factor', viewport.scale); + e.inner.appendChild(layer); + const divs = []; + const task = pdfjsLib.renderTextLayer({textContentSource: text, container: layer, viewport, textDivs: divs}); + e.textTask = task; + try { + await task.promise; + } catch (err) { + return; + } + if (e.token !== token || e.textTask !== task) return; + e.textTask = null; + e.textDivs = divs; + e.textInfo = {items: text.items, string: _pdfPageString(text)}; + if (typeof _pdfSearchMarkPage === 'function') _pdfSearchMarkPage(e); +} + +// Links: ones into the document go there (and back, _pdfShowBack); web addresses open outside. +function _pdfLinks(e, viewport, annots) { + const links = annots.filter(a => a.subtype === 'Link' && (a.url || a.dest)); + if (!links.length) return; + const layer = document.createElement('div'); + layer.className = 'pdf-link-layer'; + for (const a of links) { + const r = viewport.convertToViewportRectangle(a.rect); + const link = document.createElement('a'); + link.className = 'pdf-link'; + link.style.left = Math.min(r[0], r[2]) + 'px'; + link.style.top = Math.min(r[1], r[3]) + 'px'; + link.style.width = Math.abs(r[2] - r[0]) + 'px'; + link.style.height = Math.abs(r[3] - r[1]) + 'px'; + if (a.url) { + link.href = a.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.title = a.url; + } else { + const dest = a.dest; + link.href = '#'; + link.addEventListener('click', ev => { + ev.preventDefault(); + ev.stopPropagation(); + _pdfFollowDest(dest); + }); + } + layer.appendChild(link); + } + e.inner.appendChild(layer); +} + function _pdfStopLazy() { const v = _pdfView; if (!v) return; @@ -74,6 +240,7 @@ function _pdfStopLazy() { */ async function _pdfDraw(e, page, cssScale) { const token = ++e.token; + _pdfDropLayers(e); const prev = e.task; if (prev) { try { prev.cancel(); } catch (err) {} @@ -104,6 +271,7 @@ async function _pdfDraw(e, page, cssScale) { if (e.task === task) e.task = null; if (e.token !== token) return false; e.drawn = cssScale; + _pdfLayers(e, page, viewport, token).catch(err => console.warn('PDF page layers', e.num, err)); return true; } @@ -111,6 +279,7 @@ async function _pdfDraw(e, page, cssScale) { function _pdfLetGo(e, pdf) { e.token++; if (e.task) { try { e.task.cancel(); } catch (err) {} } + _pdfDropLayers(e); e.drawn = 0; e.canvas.width = 0; e.canvas.height = 0; @@ -139,6 +308,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { _pdfStopLazy(); let pdf = currentPdfDoc; if (!pdf) { + _pdfForgetDoc(); _pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0))); try { pdf = await _pdfLoadingTask.promise; diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js index 8110f01..03fbf98 100644 --- a/static/js/app/62-reader.js +++ b/static/js/app/62-reader.js @@ -674,6 +674,7 @@ function closeReader() { if (typeof _pdfStopLazy === 'function') _pdfStopLazy(); if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} } currentPdfDoc = null; + if (typeof _pdfForgetDoc === 'function') _pdfForgetDoc(); currentPdfBuffer = null; currentBookmarks = []; bookmarksDirty = false; diff --git a/static/js/app/66-pdf-paginated.js b/static/js/app/66-pdf-paginated.js index 57744b1..ea818be 100644 --- a/static/js/app/66-pdf-paginated.js +++ b/static/js/app/66-pdf-paginated.js @@ -46,7 +46,10 @@ function exitPdfPaginatedMode(page) { } function _pdfPaginatedClick(e) { - if (e.target.closest('a, button, input, .pdf-text-layer span')) return; + 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; @@ -116,6 +119,9 @@ function _pdfTouchEnd(e) { 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)); } @@ -226,7 +232,7 @@ async function _computePdfTextBox(page, natural) { const pageW = natural.width, pageH = natural.height; // Tier 1: text-based try { - const tc = await page.getTextContent(); + 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) { diff --git a/static/js/app/70-search.js b/static/js/app/70-search.js index 59d1c68..124742f 100644 --- a/static/js/app/70-search.js +++ b/static/js/app/70-search.js @@ -94,15 +94,9 @@ async function doReaderSearch(query) { } catch (e) {} } } else { - // PDF: collect text layer spans - const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); - const lq = query.toLowerCase(); - for (const span of spans) { - if (span.textContent.toLowerCase().includes(lq)) { - span.classList.add('reader-search-match'); - searchMatches.push(span); - } - } + // PDF: the whole document, page by page (not only the pages drawn now). + _pdfSearchAll(query); + return; } if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; @@ -131,10 +125,7 @@ function clearReaderSearchHighlights() { }); } } else { - // PDF: remove highlight class from spans - document.querySelectorAll('.reader-search-match').forEach(el => { - el.classList.remove('reader-search-match', 'active'); - }); + _pdfSearchStop(); } searchMatches = []; searchMatchIndex = -1; @@ -150,6 +141,7 @@ function clearReaderSearch() { function scrollToSearchMatch(idx) { if (!searchMatches.length) return; + if (currentPdfDoc) { _pdfShowMatch(idx); return; } _suppressScrollJumpDetect(); searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); @@ -157,6 +149,134 @@ function scrollToSearchMatch(idx) { if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; } +// --------------------------------------------------------------------------- +// PDF search: through the text of every page (58-pdf.js keeps it), in order; the matches +// marked in the text layer of the pages drawn, and of those drawn later. +// --------------------------------------------------------------------------- + +let _pdfSearchState = null; // {query, matches: [{page, start, end}], done} +let _pdfSearchGen = 0; + +function _pdfSearchCount() { + const countEl = document.getElementById('rs-search-count'); + const st = _pdfSearchState; + if (!countEl || !st) return; + const more = st.done ? '' : ' …'; + countEl.textContent = st.matches.length + ? `${searchMatchIndex >= 0 ? searchMatchIndex + 1 : '–'} / ${st.matches.length}${more}` + : (st.done ? '0' : '…'); +} + +async function _pdfSearchAll(query) { + const pdf = currentPdfDoc; + const gen = ++_pdfSearchGen; + const lq = query.toLowerCase(); + const st = {query, matches: [], done: false}; + _pdfSearchState = st; + searchMatches = st.matches; + searchMatchIndex = -1; + _pdfSearchCount(); + // The first match from the page being read on is shown as soon as it's found. + const from = pdfCurrentPage || 1; + for (let n = 1; n <= pdf.numPages; n++) { + const tc = await _pdfPageText(pdf, n); + if (gen !== _pdfSearchGen || pdf !== currentPdfDoc) return; + const {str} = _pdfPageString(tc); + const before = st.matches.length; + let i = 0; + while (lq && (i = str.indexOf(lq, i)) !== -1) { + st.matches.push({page: n, start: i, end: i + lq.length}); + i += lq.length; + } + if (st.matches.length > before) { + const e = _pdfView && _pdfView.entries[n - 1]; + if (e) _pdfSearchMarkPage(e); + if (searchMatchIndex < 0 && n >= from) { searchMatchIndex = before; _pdfShowMatch(before); } + } + if (n % 20 === 0) _pdfSearchCount(); + } + st.done = true; + if (searchMatchIndex < 0 && st.matches.length) { searchMatchIndex = 0; _pdfShowMatch(0); } + _pdfSearchCount(); +} + +/** Marks of search [st] in page [e]'s text layer (none when no search is on). */ +function _pdfSearchMarkPage(e) { + if (!e.textDivs || !e.textInfo) return; + if (e.marked) { + for (const i of e.marked) if (e.textDivs[i]) e.textDivs[i].textContent = e.textInfo.items[i].str; + e.marked = null; + } + const st = _pdfSearchState; + if (!st) return; + const {starts} = e.textInfo.string; + const items = e.textInfo.items; + // Per span, the pieces of it that match (one match can run over several spans). + const pieces = new Map(); + st.matches.forEach((m, idx) => { + if (m.page !== e.num) return; + for (let i = 0; i < items.length; i++) { + const s = starts[i], len = (items[i].str || '').length; + if (s >= m.end) break; + if (s + len <= m.start || !len) continue; + if (!pieces.has(i)) pieces.set(i, []); + pieces.get(i).push([Math.max(m.start, s) - s, Math.min(m.end, s + len) - s, idx]); + } + }); + e.marked = []; + pieces.forEach((list, i) => { + const div = e.textDivs[i]; + if (!div) return; + const str = items[i].str; + div.textContent = ''; + let at = 0; + for (const [a, b, idx] of list) { + if (a > at) div.appendChild(document.createTextNode(str.slice(at, a))); + const mark = document.createElement('mark'); + mark.className = 'reader-search-match' + (idx === searchMatchIndex ? ' active' : ''); + mark.dataset.m = idx; + mark.textContent = str.slice(a, b); + div.appendChild(mark); + at = b; + } + if (at < str.length) div.appendChild(document.createTextNode(str.slice(at))); + e.marked.push(i); + }); +} + +/** Go to match [idx]: its page, and (scrolling) the match itself into view. */ +async function _pdfShowMatch(idx) { + const st = _pdfSearchState; + const m = st && st.matches[idx]; + if (!m) return; + const gen = _pdfSearchGen; + _pdfSearchCount(); + document.querySelectorAll('.pdf-text-layer mark.reader-search-match').forEach(mk => { + mk.classList.toggle('active', +mk.dataset.m === idx); + }); + const contentEl = $('reader-content'); + const shown = readerSettings.pdfPaginated ? pdfCurrentPage : (contentEl ? _pdfTopPage(contentEl) : 0); + if (m.page !== shown || readerSettings.pdfPaginated) pdfGoToPage(m.page); + if (readerSettings.pdfPaginated || !contentEl) return; + // Scrolling: the page is drawn (and its text laid over it) once it's near the view. + for (let t = 0; t < 50; t++) { + if (gen !== _pdfSearchGen || searchMatchIndex !== idx) return; + const mk = contentEl.querySelector(`#pdf-page-${m.page} mark[data-m="${idx}"]`); + if (mk) { + _suppressScrollJumpDetect(); + mk.scrollIntoView({block: 'center'}); + return; + } + await new Promise(r => setTimeout(r, 100)); + } +} + +function _pdfSearchStop() { + _pdfSearchGen++; + _pdfSearchState = null; + if (_pdfView) _pdfView.entries.forEach(e => { if (e.marked) _pdfSearchMarkPage(e); }); +} + function readerSearchNext() { if (!searchMatches.length) return; searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; diff --git a/static/js/app/72-highlights.js b/static/js/app/72-highlights.js index cd684f7..ff55a91 100644 --- a/static/js/app/72-highlights.js +++ b/static/js/app/72-highlights.js @@ -219,8 +219,9 @@ function handleReaderSelection(e) { return; } - // Markings belong to books (an article has nowhere to keep them, 65-article.js). - if (!markerModeActive || !currentBookId) return; + // Markings belong to books (an article has nowhere to keep them, 65-article.js), and to + // EPUBs only: a PDF's text layer is there to select and copy. + if (!markerModeActive || !currentBookId || currentPdfDoc) return; // Don't hijack taps on real interactive content (footnote refs, external // links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless // used only as an internal jump target (sanitizeEpubHtml diff --git a/static/js/sw.js b/static/js/sw.js index aa356a3..0349357 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v164'; +const CACHE = 'diora-v165'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',