Merge branch 'fast-open'

This commit is contained in:
mrwnslz 2026-10-08 18:51:12 +02:00
commit 52d8362da9

View file

@ -1815,11 +1815,21 @@ async function parseEpub(arrayBuffer) {
.map(ref => manifest[ref.getAttribute('idref')]?.href) .map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean); .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 = []; const chapters = [];
let slice = performance.now();
for (let i = 0; i < spineItems.length; i++) { for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i]; const href = spineItems[i];
if (texts[i] == null) continue; // missing
try { 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. // 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. // This shrinks the string before regex and DOMParser work on it.
chapterText = chapterText.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ''); chapterText = chapterText.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
@ -1828,8 +1838,12 @@ async function parseEpub(arrayBuffer) {
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs); const sanitized = sanitizeEpubHtml(withBlobs);
chapters.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`); chapters.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
if (i % 8 === 0) await new Promise(r => requestAnimationFrame(r)); // Let the page breathe (spinner, taps) every ~50 ms of work, not after a fixed count.
} catch (e) { /* skip missing */ } 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}; return {title, author, chapters, toc, imageMap};
@ -2222,11 +2236,12 @@ function _cmpProgress(anchorA, fracA, anchorB, fracB) {
const _BOOK_CACHE_STORE = 'books'; const _BOOK_CACHE_STORE = 'books';
const _BOOK_META_STORE = 'book_meta'; const _BOOK_META_STORE = 'book_meta';
const _BOOK_ANNOTATIONS_STORE = 'book_annotations'; 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; const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
function _openBookCacheDb() { function _openBookCacheDb() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open('diora_book_cache', 2); const req = indexedDB.open('diora_book_cache', 3);
req.onupgradeneeded = e => { req.onupgradeneeded = e => {
const db = e.target.result; const db = e.target.result;
if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE))
@ -2235,12 +2250,98 @@ function _openBookCacheDb() {
db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'}); db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE)) if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE))
db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'}); 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.onsuccess = e => resolve(e.target.result);
req.onerror = () => reject(); 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) { async function _getCachedBook(bookId) {
try { try {
const db = await _openBookCacheDb(); const db = await _openBookCacheDb();
@ -3754,22 +3855,29 @@ async function _openBookOnce(bookId) {
currentPdfBuffer = plain; currentPdfBuffer = plain;
} else { } else {
currentPdfBuffer = null; 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; title = result.title || title;
author = result.author || author; author = result.author || author;
toc = result.toc; toc = result.toc;
currentImageMap = result.imageMap; currentImageMap = result.imageMap;
// Inject chapters progressively so the browser stays responsive on large books // Inject chapters progressively so the browser stays responsive on large books:
const BATCH = 8; // a batch per ~50 ms of work (a fixed count waited a frame every 8 chapters).
for (let i = 0; i < result.chapters.length; i += BATCH) { const tmp = document.createElement('div');
for (let i = 0; i < result.chapters.length;) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const tmp = document.createElement('div'); const start = performance.now();
for (let j = i; j < Math.min(i + BATCH, result.chapters.length); j++) { do {
tmp.innerHTML = result.chapters[j]; tmp.innerHTML = result.chapters[i++];
while (tmp.firstChild) frag.appendChild(tmp.firstChild); while (tmp.firstChild) frag.appendChild(tmp.firstChild);
} } while (i < result.chapters.length && performance.now() - start < 50);
contentEl.appendChild(frag); 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 — // Used to decide whole-percent vs one-decimal display further down —
// see _formatBookPercent(). // see _formatBookPercent().