Compare commits

...

5 commits

Author SHA1 Message Date
3f2fcec3b8 sw v97 (Fußnoten-Fix und schnelleres Öffnen nach v96 aus aa4cf23)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 7m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:52:16 +02:00
8662f381ed Merge remote-tracking branch 'origin/master' 2026-10-08 18:52:10 +02:00
52d8362da9 Merge branch 'fast-open' 2026-10-08 18:51:12 +02:00
3f2e309c9c Reader: Fußnoten und Seitenzahlen in Verlags-EPUBs; sw v96
- <a id="x"/> (XHTML) blieb beim Parsen als HTML offen und umschloss den
  folgenden Text, in jedem weiteren Absatz neu (Suhrkamp/transpect): die
  inline-Elemente werden vorher geschlossen.
- Seitenzahlen der Druckausgabe (epub:type="pagebreak") als kleine graue
  Marke statt am Text klebend („3Anton Jäger“).
- Fußnoten-Popover: Absatz der Notiz auch bei id auf einem inline-Element,
  ohne eigene Nummer und Seitenzahl, mehrabsätzige Noten (Gedichte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:51:05 +02:00
077612e7bb Bücher schneller öffnen: Kapitel parallel entpacken, vorbereitete Bücher merken
Gemessen mit Das Kapital (148 Kapitel, 26 000 Absätze) im diora Reader, CPU
6-fach gedrosselt wie auf einem E-Reader: Öffnen 2,4 s → 1,2 s.
- parseEpub entpackt alle Kapitel auf einmal statt eines nach dem anderen
  (jedes wartete auf JSZips eigene Ticks) und pausiert nach ~50 ms Arbeit statt
  alle 8 Kapitel einen Frame; ebenso das Einfügen der Kapitel
- Vorbereitete Bücher (entpackt und bereinigt) liegen verschlüsselt mit dem
  Buchschlüssel in IndexedDB („parsed“): gültig für eine Fassung der Buchdatei
  (IV) und des Vorbereitungs-Codes (Hash seines Quelltexts – eine Änderung am
  Sanitizer bereitet jedes Buch neu vor); Bilder bleiben in der Buchdatei, die
  Kapitel tragen „diora-img:<Pfad>“ statt Blob-URLs. Nach 4 Wochen ohne Öffnen weg.
  Geprüft: Text, Absätze, Bilder, Fußnotenlinks, Inhaltsverzeichnis identisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:41:56 +02:00
3 changed files with 161 additions and 15 deletions

View file

@ -1814,6 +1814,10 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Page numbers of the printed edition (epub:type="pagebreak") */
.reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; }
.reader-content .epub-pagenum:empty { display:none; }
/* Highlight marks */
.reader-no-bold * { font-weight: inherit !important; }
.reader-content img { max-width: 100%; height: auto; display: block; }

View file

@ -1815,11 +1815,21 @@ async function parseEpub(arrayBuffer) {
.map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean);
// All chapters unpacked at once: one after the other, every chapter waits for
// JSZip's own ticks (a book like Das Kapital has 148 of them).
const texts = await Promise.all(spineItems.map(href => {
const f = zip.file(href);
return f ? f.async('text').catch(() => null) : null;
}));
const chapters = [];
let slice = performance.now();
for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i];
if (texts[i] == null) continue; // missing
try {
let chapterText = await zip.file(href).async('text');
let chapterText = texts[i];
texts[i] = null;
// Strip style/script blocks early — publisher EPUBs often embed large base64 fonts in CSS.
// This shrinks the string before regex and DOMParser work on it.
chapterText = chapterText.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
@ -1828,8 +1838,12 @@ async function parseEpub(arrayBuffer) {
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs);
chapters.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
if (i % 8 === 0) await new Promise(r => requestAnimationFrame(r));
} catch (e) { /* skip missing */ }
// Let the page breathe (spinner, taps) every ~50 ms of work, not after a fixed count.
if (performance.now() - slice > 50) {
await new Promise(r => setTimeout(r, 0));
slice = performance.now();
}
} catch (e) { /* skip unreadable */ }
}
return {title, author, chapters, toc, imageMap};
@ -1942,7 +1956,16 @@ function _looksLikeFootnoteLink(el, href) {
return !!(el.closest('sup') || el.querySelector('sup'));
}
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
// knows no self-closing <a id="x"/>: the anchor stays open and swallows the
// text after it — and being a formatting element it is re-opened in every
// following paragraph (Suhrkamp/transpect books put one in each heading and
// note). Close the inline ones; block elements are left alone so the block
// indexes that highlights are anchored to stay the same.
const _SELF_CLOSING_INLINE = /<(a|span|b|i|em|strong|sup|sub|small|u|s|code|font|abbr|cite|q|mark|var)(\s(?:[^<>"']|"[^"]*"|'[^']*')*)?\/>/gi;
function sanitizeEpubHtml(html) {
html = html.replace(_SELF_CLOSING_INLINE, '<$1$2></$1>');
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove());
@ -1957,6 +1980,11 @@ function sanitizeEpubHtml(html) {
// Remove any non-blob, non-data src that slipped through (broken relative paths)
if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src');
}
// The printed edition's page numbers ("[12]") as small marks, not glued to the text.
const etype = el.getAttribute('epub:type') || '';
if (/\bpagebreak\b/.test(etype) || el.getAttribute('role') === 'doc-pagebreak') {
el.classList.add('epub-pagenum');
}
if (el.tagName === 'A') {
const href = el.getAttribute('href') || '';
if (href.startsWith('http://') || href.startsWith('https://')) {
@ -2210,11 +2238,12 @@ function _cmpProgress(anchorA, fracA, anchorB, fracB) {
const _BOOK_CACHE_STORE = 'books';
const _BOOK_META_STORE = 'book_meta';
const _BOOK_ANNOTATIONS_STORE = 'book_annotations';
const _BOOK_PARSED_STORE = 'parsed'; // prepared chapters, see _getParsedBook
const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
function _openBookCacheDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('diora_book_cache', 2);
const req = indexedDB.open('diora_book_cache', 3);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE))
@ -2223,12 +2252,98 @@ function _openBookCacheDb() {
db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE))
db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_PARSED_STORE))
db.createObjectStore(_BOOK_PARSED_STORE, {keyPath: 'id'});
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = () => reject();
});
}
// ---------------------------------------------------------------------------
// Prepared books: an EPUB's chapters after unpacking and sanitizing, so opening
// it again skips both (for a book like Das Kapital that's most of the time an
// e-reader needs to open it). Encrypted with the book key like everything else
// on the device. Valid for one version of the book file (its IV) and of the
// code that prepares chapters (a hash of its source: changing the sanitizer
// makes every book be prepared anew). Images stay in the book file: the
// chapters keep "diora-img:<path>" in place of the session's blob URLs.
// ---------------------------------------------------------------------------
let _parseCodeSig = null;
function _parsePreparationSig() {
if (_parseCodeSig) return _parseCodeSig;
const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
.map(f => String(f)).join('\n');
let h = 5381;
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
return (_parseCodeSig = (h >>> 0).toString(36) + '.' + src.length);
}
async function _getParsedBook(bookId, dataIv, key) {
try {
const db = await _openBookCacheDb();
const row = await new Promise(resolve => {
const req = db.transaction(_BOOK_PARSED_STORE).objectStore(_BOOK_PARSED_STORE).get(bookId);
req.onsuccess = e => resolve(e.target.result || null);
req.onerror = () => resolve(null);
});
if (!row || row.sig !== dataIv + ':' + _parsePreparationSig()) return null;
const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: row.iv}, key, row.ct);
return JSON.parse(new TextDecoder().decode(plain));
} catch (e) { return null; }
}
async function _setParsedBook(bookId, dataIv, key, result) {
try {
// Blob URLs live as long as this page; the stored chapters name the image instead.
const pathOf = {};
for (const [path, url] of Object.entries(result.imageMap || {})) pathOf[url] = path;
const blobRe = /blob:[^"'\s)]+/g;
const chapters = result.chapters.map(c => c.replace(blobRe, u => pathOf[u] ? 'diora-img:' + pathOf[u] : u));
const doc = {title: result.title, author: result.author, toc: result.toc, chapters,
images: Object.keys(result.imageMap || {})};
// Raw bytes, not base64: IndexedDB keeps an ArrayBuffer as it is (megabytes for a big book).
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(JSON.stringify(doc)));
const db = await _openBookCacheDb();
await new Promise(resolve => {
const tx = db.transaction(_BOOK_PARSED_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_PARSED_STORE);
store.put({id: bookId, sig: dataIv + ':' + _parsePreparationSig(), iv, ct, cached_at: Date.now()});
// Prepared books not opened for a while go (they come back on the next open).
const all = store.getAll();
all.onsuccess = () => all.result.forEach(r => {
if (r.id !== bookId && Date.now() - (r.cached_at || 0) > _BOOK_CACHE_EVICT_MS) store.delete(r.id);
});
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch (e) {}
}
/** A prepared book back to what parseEpub returns: fresh blob URLs for its images. */
async function _restoreParsedBook(doc, epubBytes) {
const imageMap = {};
if (doc.images && doc.images.length) {
const zip = await JSZip.loadAsync(epubBytes);
await Promise.all(doc.images.map(async path => {
const f = zip.file(path);
if (!f) return;
try {
const buf = await f.async('arraybuffer');
const ext = path.split('.').pop().toLowerCase();
const type = {jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
svg: 'image/svg+xml', webp: 'image/webp'}[ext] || '';
imageMap[path] = URL.createObjectURL(new Blob([buf], type ? {type} : {}));
} catch (e) {}
}));
}
const placeholder = /diora-img:([^"'\s)]+)/g;
const chapters = doc.chapters.map(c => c.replace(placeholder, (m, path) => imageMap[path] || ''));
return {title: doc.title, author: doc.author, toc: doc.toc, chapters, imageMap};
}
async function _getCachedBook(bookId) {
try {
const db = await _openBookCacheDb();
@ -3743,22 +3858,29 @@ async function _openBookOnce(bookId) {
currentPdfBuffer = plain;
} else {
currentPdfBuffer = null;
const result = await parseEpub(plain);
// Prepared before (same book file, same preparation code): only the images to unpack.
const prepared = await _getParsedBook(bookId, data_iv, key);
const result = prepared ? await _restoreParsedBook(prepared, plain) : await parseEpub(plain);
if (!prepared) {
// Kept for the next open once the book is on screen (encrypting takes a moment).
setTimeout(() => _setParsedBook(bookId, data_iv, key, result), 3000);
}
title = result.title || title;
author = result.author || author;
toc = result.toc;
currentImageMap = result.imageMap;
// Inject chapters progressively so the browser stays responsive on large books
const BATCH = 8;
for (let i = 0; i < result.chapters.length; i += BATCH) {
// Inject chapters progressively so the browser stays responsive on large books:
// a batch per ~50 ms of work (a fixed count waited a frame every 8 chapters).
const tmp = document.createElement('div');
for (let i = 0; i < result.chapters.length;) {
const frag = document.createDocumentFragment();
const tmp = document.createElement('div');
for (let j = i; j < Math.min(i + BATCH, result.chapters.length); j++) {
tmp.innerHTML = result.chapters[j];
const start = performance.now();
do {
tmp.innerHTML = result.chapters[i++];
while (tmp.firstChild) frag.appendChild(tmp.firstChild);
}
} while (i < result.chapters.length && performance.now() - start < 50);
contentEl.appendChild(frag);
await new Promise(r => requestAnimationFrame(r));
if (i < result.chapters.length) await new Promise(r => setTimeout(r, 0));
}
// Used to decide whole-percent vs one-decimal display further down —
// see _formatBookPercent().
@ -6031,11 +6153,31 @@ function showFootnotePopover(link) {
dismissFootnotePopover();
const targetEl = document.getElementById(link.dataset.footnoteRef);
if (!targetEl) return;
const container = targetEl.closest('.footnote') || targetEl.parentElement;
// The note's paragraph, also when the id sits on an inline element in it
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
const container = targetEl.closest('.footnote') ||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
if (!container) return;
const clone = container.cloneNode(true);
clone.querySelector('sup')?.remove();
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
// The note's own number at its start, a link back to the text ("1 Note…").
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
const num = first ? first.textContent.trim() : '';
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
if (!clone.textContent.trim()) {
let len = 0;
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
if (!next.textContent.trim()) continue;
clone.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
}
const popover = document.createElement('div');
popover.className = 'footnote-popover';

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v96';
const CACHE = 'diora-v97';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',