Compare commits
5 commits
aa4cf23b58
...
3f2fcec3b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f2fcec3b8 | |||
| 8662f381ed | |||
| 52d8362da9 | |||
| 3f2e309c9c | |||
| 077612e7bb |
3 changed files with 161 additions and 15 deletions
|
|
@ -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.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; }
|
.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 */
|
/* Highlight marks */
|
||||||
.reader-no-bold * { font-weight: inherit !important; }
|
.reader-no-bold * { font-weight: inherit !important; }
|
||||||
.reader-content img { max-width: 100%; height: auto; display: block; }
|
.reader-content img { max-width: 100%; height: auto; display: block; }
|
||||||
|
|
|
||||||
170
static/js/app.js
170
static/js/app.js
|
|
@ -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};
|
||||||
|
|
@ -1942,7 +1956,16 @@ function _looksLikeFootnoteLink(el, href) {
|
||||||
return !!(el.closest('sup') || el.querySelector('sup'));
|
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) {
|
function sanitizeEpubHtml(html) {
|
||||||
|
html = html.replace(_SELF_CLOSING_INLINE, '<$1$2></$1>');
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove());
|
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)
|
// Remove any non-blob, non-data src that slipped through (broken relative paths)
|
||||||
if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src');
|
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') {
|
if (el.tagName === 'A') {
|
||||||
const href = el.getAttribute('href') || '';
|
const href = el.getAttribute('href') || '';
|
||||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
|
|
@ -2210,11 +2238,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))
|
||||||
|
|
@ -2223,12 +2252,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();
|
||||||
|
|
@ -3743,22 +3858,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 frag = document.createDocumentFragment();
|
|
||||||
const tmp = document.createElement('div');
|
const tmp = document.createElement('div');
|
||||||
for (let j = i; j < Math.min(i + BATCH, result.chapters.length); j++) {
|
for (let i = 0; i < result.chapters.length;) {
|
||||||
tmp.innerHTML = result.chapters[j];
|
const frag = document.createDocumentFragment();
|
||||||
|
const start = performance.now();
|
||||||
|
do {
|
||||||
|
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().
|
||||||
|
|
@ -6031,11 +6153,31 @@ function showFootnotePopover(link) {
|
||||||
dismissFootnotePopover();
|
dismissFootnotePopover();
|
||||||
const targetEl = document.getElementById(link.dataset.footnoteRef);
|
const targetEl = document.getElementById(link.dataset.footnoteRef);
|
||||||
if (!targetEl) return;
|
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;
|
if (!container) return;
|
||||||
|
|
||||||
const clone = container.cloneNode(true);
|
const clone = container.cloneNode(true);
|
||||||
clone.querySelector('sup')?.remove();
|
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');
|
const popover = document.createElement('div');
|
||||||
popover.className = 'footnote-popover';
|
popover.className = 'footnote-popover';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v96';
|
const CACHE = 'diora-v97';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue