diff --git a/static/js/app.js b/static/js/app.js index b5d9891..5465c34 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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(/]*>[\s\S]*?<\/style>/gi, ''); @@ -1828,8 +1838,12 @@ async function parseEpub(arrayBuffer) { const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const sanitized = sanitizeEpubHtml(withBlobs); chapters.push(`
${sanitized}
`); - 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}; @@ -2222,11 +2236,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)) @@ -2235,12 +2250,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:" 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(); @@ -3754,22 +3855,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().