EPUB reader: font picker + performance fixes for large books
All checks were successful
Build and push Docker image / build (push) Successful in 1m17s
Test / test (push) Successful in 14s

- Add font family selector (Serif/Sans/Verdana/Mono) to reader settings panel,
  saved per book in localStorage
- Strip <style>/<script> blocks before regex and DOMParser processing to avoid
  working over large base64-embedded font CSS in publisher EPUBs
- Parallelize image blob URL creation with Promise.all instead of sequential await
- Inject chapter HTML progressively in batches with requestAnimationFrame yields
  to keep the UI responsive when loading many-chapter books

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-04-29 11:40:58 +02:00
parent 9241d6170b
commit 2d488fd542

View file

@ -2467,38 +2467,45 @@ async function parseEpub(arrayBuffer) {
};
});
// 4. Build image map: abs zip path → blob URL
// 4. Build image map: abs zip path → blob URL (parallel)
const imageMap = {};
for (const {href, mediaType} of Object.values(manifest)) {
if (mediaType.startsWith('image/')) {
try {
const buf = await zip.file(href).async('arraybuffer');
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
} catch (e) { /* missing asset */ }
}
}
await Promise.all(
Object.values(manifest)
.filter(({mediaType}) => mediaType.startsWith('image/'))
.map(async ({href, mediaType}) => {
try {
const buf = await zip.file(href).async('arraybuffer');
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
} catch (e) { /* missing asset */ }
})
);
// 5. Parse TOC
const toc = await _parseEpubToc(zip, opfDoc, manifest);
// 6. Get spine and concatenate chapters
// 6. Get spine items; chapters are returned raw for progressive DOM injection
const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
.map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean);
const parts = [];
const chapters = [];
for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i];
try {
const chapterText = await zip.file(href).async('text');
let chapterText = await zip.file(href).async('text');
// 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, '');
chapterText = chapterText.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : '';
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs);
parts.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));
} catch (e) { /* skip missing */ }
}
return {title, author, html: parts.join('\n'), toc, imageMap};
return {title, author, chapters, toc, imageMap};
}
async function _parseEpubToc(zip, opfDoc, manifest) {
@ -2736,6 +2743,7 @@ async function _evictBookCache(bookList) {
// Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false;
let currentPdfDoc = null;
@ -3196,7 +3204,18 @@ async function openBook(bookId) {
author = result.author || author;
toc = result.toc;
currentImageMap = result.imageMap;
contentEl.innerHTML = result.html;
// Inject chapters progressively so the browser stays responsive on large books
const BATCH = 8;
for (let i = 0; i < result.chapters.length; i += BATCH) {
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];
while (tmp.firstChild) frag.appendChild(tmp.firstChild);
}
contentEl.appendChild(frag);
await new Promise(r => requestAnimationFrame(r));
}
}
currentBookToc = toc;
@ -3561,6 +3580,7 @@ function jumpToTocEntry(href) {
function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false });
try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
@ -3586,6 +3606,13 @@ function applyReaderSettings(isPdf) {
contentEl.style.fontSize = readerSettings.fontSize + 'px';
contentEl.style.lineHeight = readerSettings.lineHeight;
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
const fontMap = {
serif: "Georgia, 'Times New Roman', serif",
sans: 'system-ui, -apple-system, sans-serif',
humanist: 'Verdana, Geneva, sans-serif',
mono: "'Courier New', monospace",
};
contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif;
if (_currentPositionAnchor && currentBookId) {
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
}
@ -3626,6 +3653,10 @@ function toggleSettingsPanel() {
<label>Line <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
<label>Width <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
<button class="btn btn-sm" id="rs-width-full">Full</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'mono' ? 'active' : ''}" data-rs-font="mono">Mono</button>
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dark</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button>
@ -3676,6 +3707,15 @@ function toggleSettingsPanel() {
saveReaderSettings();
});
panel.querySelectorAll('[data-rs-font]').forEach(btn => {
btn.addEventListener('click', () => {
readerSettings.fontFamily = btn.dataset.rsFont;
panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn));
applyReaderSettings(false);
saveReaderSettings();
});
});
panel.querySelectorAll('[data-rs-theme]').forEach(btn => {
btn.addEventListener('click', () => {
readerSettings.theme = btn.dataset.rsTheme;