PDF: markierbarer Text, Suche im ganzen Dokument, anklickbare Links mit Zurück (sw CACHE v165)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m32s
Test / e2e (push) Failing after 1m30s

Über jeder gezeichneten Seite liegt die Textebene von pdf.js (auswählen, kopieren) und eine
Ebene mit den Links: Sprünge im Dokument gehen dorthin, ein Knopf führt zurück; Web-Adressen
öffnen außerhalb. Die Suche geht durch den Text aller Seiten (je Dokument einmal geholt, auch
für den Zuschnitt im Seitenmodus) statt nur durch die gerade gezeichneten, markiert den Treffer
genau und springt in beiden Modi hin. Tippen und Wischen auf Text blättern weiter, außer
nach einer Auswahl; Markierungen bleiben EPUBs vorbehalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-03 18:16:27 +01:00
parent 6a3cd23095
commit 65a01569f9
7 changed files with 335 additions and 31 deletions

View file

@ -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. */

View file

@ -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;

View file

@ -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;

View file

@ -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) {

View file

@ -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;

View file

@ -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
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml

View file

@ -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',