diora-web/static/js/app/40-epub.js

324 lines
15 KiB
JavaScript
Raw Permalink Normal View History

'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};
}
// 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') {
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet) - 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts- verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel; ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite. - 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen Seite (eine fast leere Seite wird nicht unendlich groß); nächste und vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn' gemeldet (E-Ink-Refresh). - pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf, Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab). - Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten immer „Seite 1“). - Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu, wenn er ruht. - openBook hängt seine Listener über _readerListen an; closeReader nimmt sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und ein zweites Buch geben das alte Dokument frei; der entschlüsselte PDF-Puffer wird nicht mehr gehalten. - Invertieren dreht die Farbtöne zurück (Fotos keine Negative). - e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten, Outline, Links und einer fast leeren Seite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
const pdf = await _pdfLoad(new Uint8Array(buf)).promise;
try {
return pdf.numPages;
} finally {
try { pdf.destroy(); } catch (e) {}
}
}
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(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<script\b[^>]*>[\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)) || '';
}
// <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. 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 (<a class="footnote" href="#ftn.fn1">, 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: <p><a id="F258"></a><a href="…#N258">1</a> Note…</p>).
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 <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 (!(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;
}