32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei 'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale Funktionen und let/const bleiben global, die App-Schichten (native.js, native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen die Dateien; diora-android build_web nimmt static/js rekursiv. Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im Reader, Hell/Dunkel). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
'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(/<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);
|
|
chapters.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
|
|
// 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};
|
|
}
|
|
|
|
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)) || '';
|
|
}
|
|
// <img src="...">
|
|
html = html.replace(/(<img\b[^>]*?)\bsrc="([^"]*)"/gi,
|
|
(_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; });
|
|
// SVG <image xlink:href="...">
|
|
html = html.replace(/(<image\b[^>]*?)\bxlink:href="([^"]*)"/gi,
|
|
(_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; });
|
|
// SVG <image href="...">
|
|
html = html.replace(/(<image\b[^>]*?)\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 <sup> — in either direction
|
|
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
|
|
// rather than only the single hardcoded class name.
|
|
function _looksLikeFootnoteLink(el, href) {
|
|
if (!href.includes('#')) return false;
|
|
const cls = (el.getAttribute('class') || '').toLowerCase();
|
|
if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true;
|
|
const epubType = (el.getAttribute('epub:type') || '').toLowerCase();
|
|
if (epubType.includes('noteref')) return true;
|
|
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) {
|
|
html = html.replace(_SELF_CLOSING_INLINE, '<$1$2></$1>');
|
|
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 (_looksLikeFootnoteLink(el, href)) {
|
|
// The whole book is one concatenated DOM, so a fragment id resolves
|
|
// via getElementById regardless of which chapter file it originally
|
|
// pointed into — strip any "../Text/other.html" path prefix and keep
|
|
// just the id after the last '#'.
|
|
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
|
|
el.removeAttribute('href');
|
|
el.style.cursor = 'pointer';
|
|
} else {
|
|
el.removeAttribute('href');
|
|
el.style.cursor = 'default';
|
|
}
|
|
}
|
|
});
|
|
|
|
return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML;
|
|
}
|