'use strict'; // --------------------------------------------------------------------------- // EPUB parser (requires JSZip) // --------------------------------------------------------------------------- function resolveEpubPath(base, relative) { if (!relative) return ''; if (relative.startsWith('/')) return relative.slice(1); const hashIdx = relative.indexOf('#'); const frag = hashIdx >= 0 ? relative.slice(hashIdx) : ''; const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative; const parts = (base + rel).split('/'); const resolved = []; for (const p of parts) { if (p === '..') resolved.pop(); else if (p !== '.') resolved.push(p); } return resolved.join('/') + frag; } async function parseEpub(arrayBuffer) { const zip = await JSZip.loadAsync(arrayBuffer); // 1. Find OPF via container.xml const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); const rootfileEl = containerDoc.querySelector('rootfile'); if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)'); const opfPath = rootfileEl.getAttribute('full-path'); const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; // 2. Parse OPF const opfText = await zip.file(opfPath).async('text'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel'; const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor'; // 3. Build manifest: id → {href, mediaType, properties} const manifest = {}; opfDoc.querySelectorAll('manifest > item').forEach(item => { manifest[item.getAttribute('id')] = { href: opfDir + item.getAttribute('href'), mediaType: item.getAttribute('media-type') || '', properties: item.getAttribute('properties') || '', }; }); // 4. Build image map: abs zip path → blob URL (parallel) const imageMap = {}; 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 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); // 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 = 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, ''); chapterText = chapterText.replace(/]*>[\s\S]*?<\/script>/gi, ''); const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : ''; const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const sanitized = sanitizeEpubHtml(withBlobs); chapters.push(`
${sanitized}
`); // 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}; } // A book's length in printed pages, from its file without drawing it: a PDF's pages, an EPUB's // spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts. async function bookPagesOfFile(buf, type) { if (type === 'pdf') { const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise; const n = pdf.numPages; pdf.destroy(); return n; } const zip = await JSZip.loadAsync(buf); const containerXml = await zip.file('META-INF/container.xml').async('text'); const opfPath = new DOMParser().parseFromString(containerXml, 'application/xml').querySelector('rootfile')?.getAttribute('full-path'); if (!opfPath) return 0; const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); const hrefs = {}; opfDoc.querySelectorAll('manifest > item').forEach(item => { hrefs[item.getAttribute('id')] = opfDir + item.getAttribute('href'); }); let chars = 0; for (const ref of opfDoc.querySelectorAll('spine > itemref')) { const f = zip.file(hrefs[ref.getAttribute('idref')] || ''); if (!f) continue; const html = (await f.async('text').catch(() => '')) .replace(/]*>[\s\S]*?<\/style>/gi, '').replace(/]*>[\s\S]*?<\/script>/gi, ''); const body = new DOMParser().parseFromString(html, 'text/html').body; chars += body ? body.textContent.length : 0; } return chars ? Math.max(1, Math.round(chars / CHARS_PER_BOOK_PAGE)) : 0; } async function _parseEpubToc(zip, opfDoc, manifest) { // Try EPUB3 nav document const navItem = Object.values(manifest).find(m => m.properties.includes('nav')); if (navItem) { try { const navText = await zip.file(navItem.href).async('text'); const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml'); const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav'); if (tocNav) { const ol = tocNav.querySelector('ol'); if (ol) return _parseTocOl(ol, navItem.href, 0); } } catch (e) {} } // Fall back to EPUB2 NCX const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml'); if (ncxItem) { try { const ncxText = await zip.file(ncxItem.href).async('text'); const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml'); const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : ''; return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0); } catch (e) {} } return []; } function _parseTocOl(ol, navHref, depth) { if (!ol || depth > 5) return []; const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : ''; const items = []; for (const li of Array.from(ol.children)) { const a = li.querySelector(':scope > a') || li.querySelector(':scope > span'); if (a) { const rawHref = a.getAttribute('href') || ''; items.push({ label: a.textContent.trim(), href: rawHref ? resolveEpubPath(navDir, rawHref) : '', depth, }); } const childOl = li.querySelector(':scope > ol'); if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1)); } return items; } function _parseNcxNavMap(navMap, ncxDir, depth) { if (!navMap || depth > 5) return []; const items = []; for (const navPoint of Array.from(navMap.children)) { if (navPoint.tagName !== 'navPoint') continue; const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || ''; const src = navPoint.querySelector('content')?.getAttribute('src') || ''; items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth}); items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1)); } return items; } function _resolveImageBlob(imageMap, absPath) { if (imageMap[absPath]) return imageMap[absPath]; // Fallback: match by decoded filename only const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase(); for (const [k, v] of Object.entries(imageMap)) { if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v; } return null; } // Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it. // This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes. function _injectImageBlobs(html, chapterDir, imageMap) { function subst(src) { if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src; return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || ''; } // html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bhref="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); return html; } // EPUBs mark footnote/endnote reference links very inconsistently across // editions/tools — some use class="footnote" (same-file '#frag' href), some // use the EPUB3 epub:type="noteref" attribute, and some (e.g. older // Calibre/zeno.org conversions) use neither and just wrap a plain link to // another chapter file's fragment in a — in either direction // (… or …). Recognize all of these // rather than only the single hardcoded class name. Also role="doc-noteref", // more note classes, and a bare note number as the link's text ("3", "[12]", "*"). function _looksLikeFootnoteLink(el, href) { if (!href.includes('#')) return false; const cls = (el.getAttribute('class') || '').toLowerCase(); if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; if (/\b(fnref|noteref|footnote-?ref|endnote-?ref)\b/.test(cls)) return true; const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); if (epubType.includes('noteref')) return true; if ((el.getAttribute('role') || '').toLowerCase() === 'doc-noteref') return true; if (el.closest('sup') || el.querySelector('sup')) return true; return /^\s*[\[(]?\s*(\d{1,4}|[ivxlc]{1,6}|[a-z]|[*†‡§¶]+)\s*[\])]?\s*$/i.test(el.textContent || ''); } // The number at the start of a note that links back to the text ("3 Endnote…") // is a way back, not a reference to another note. const _NOTE_BOX_SELECTOR = '.footnote, .endnote, aside, [role="doc-footnote"], [role="doc-endnote"], ' + '[epub\\:type~="footnote"], [epub\\:type~="endnote"], [epub\\:type~="rearnote"]'; function _isNoteBacklink(el) { const role = (el.getAttribute('role') || '').toLowerCase(); const type = (el.getAttribute('epub:type') || '').toLowerCase(); if (role === 'doc-backlink' || /backlink/.test(type)) return true; // Inside a note's box. Not the link itself: a reference can carry the class // "footnote" too (, Suhrkamp/DocBook). if (el.parentElement && el.parentElement.closest(_NOTE_BOX_SELECTOR)) return true; const block = el.closest('p, li, div, dd'); if (!block) return false; // A note: the block is a link target itself, or holds an empty anchor that is // (zeno.org / e-artnow:

1 Note…

). const anchored = block.id || el.id || Array.prototype.some.call(block.querySelectorAll('[id]'), x => x !== el && !(x.textContent || '').trim()); if (!anchored) return false; const text = (block.textContent || '').trim(); const firstLink = Array.prototype.find.call(block.querySelectorAll('a'), a => (a.textContent || '').trim()); return firstLink === el && text.startsWith((el.textContent || '').trim()); } // Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML // knows no self-closing : 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) { html = html.replace(_SELF_CLOSING_INLINE, '<$1$2>'); const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('on') || attr.name === 'title') el.removeAttribute(attr.name); }); const tag = el.tagName.toLowerCase(); if (tag === 'img') { const src = el.getAttribute('src') || ''; // Remove any non-blob, non-data src that slipped through (broken relative paths) 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') { const href = el.getAttribute('href') || ''; if (href.startsWith('http://') || href.startsWith('https://')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); } else if (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) { // data-footnote-ref (the id) marks the link as a reference; the href // as written (data-footnote-href) finds the note in the chapter file it // points into (78-footnotes.js) — ids like "fn1" repeat across files. el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); el.dataset.footnoteHref = href; el.removeAttribute('href'); el.style.cursor = 'pointer'; } else { // A link within the book (a cross-reference, a note's way back): kept // for the apps, which follow it (diora-android native-ui.js). if (href && !/^[a-z]+:/i.test(href)) el.dataset.internalHref = href; el.removeAttribute('href'); el.style.cursor = 'default'; } } }); return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; }