EPUB reader: font picker + performance fixes for large books
- 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:
parent
9241d6170b
commit
2d488fd542
1 changed files with 55 additions and 15 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue