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 = {}; const imageMap = {};
for (const {href, mediaType} of Object.values(manifest)) { await Promise.all(
if (mediaType.startsWith('image/')) { Object.values(manifest)
try { .filter(({mediaType}) => mediaType.startsWith('image/'))
const buf = await zip.file(href).async('arraybuffer'); .map(async ({href, mediaType}) => {
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType})); try {
} catch (e) { /* missing asset */ } const buf = await zip.file(href).async('arraybuffer');
} imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
} } catch (e) { /* missing asset */ }
})
);
// 5. Parse TOC // 5. Parse TOC
const toc = await _parseEpubToc(zip, opfDoc, manifest); 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')) const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
.map(ref => manifest[ref.getAttribute('idref')]?.href) .map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean); .filter(Boolean);
const parts = []; const chapters = [];
for (let i = 0; i < spineItems.length; i++) { for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i]; const href = spineItems[i];
try { 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 chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : '';
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs); 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 */ } } 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) { async function _parseEpubToc(zip, opfDoc, manifest) {
@ -2736,6 +2743,7 @@ async function _evictBookCache(bookList) {
// Reader settings // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false; let readerSettingsPanelOpen = false;
let currentPdfDoc = null; let currentPdfDoc = null;
@ -3196,7 +3204,18 @@ async function openBook(bookId) {
author = result.author || author; author = result.author || author;
toc = result.toc; toc = result.toc;
currentImageMap = result.imageMap; 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; currentBookToc = toc;
@ -3561,6 +3580,7 @@ function jumpToTocEntry(href) {
function loadReaderSettings(bookId) { function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides // Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }); pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false });
try { try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); 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.fontSize = readerSettings.fontSize + 'px';
contentEl.style.lineHeight = readerSettings.lineHeight; contentEl.style.lineHeight = readerSettings.lineHeight;
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); 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) { if (_currentPositionAnchor && currentBookId) {
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); 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>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> <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" 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 === '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 === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button> <button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button>
@ -3676,6 +3707,15 @@ function toggleSettingsPanel() {
saveReaderSettings(); 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 => { panel.querySelectorAll('[data-rs-theme]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
readerSettings.theme = btn.dataset.rsTheme; readerSettings.theme = btn.dataset.rsTheme;