Books: read DRM-free MOBI, AZW and AZW3; refuse Kindle files with DRM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
7 files changed
+138
-21
No files matched your search
+55
-14
@@ -3,7 +3,8 @@
|
||||
// they follow you between devices. Reading uses epub.js, pdf.js and mammoth from /vendor/reader, loaded only when a book is opened or added.
|
||||
const rdApi = (p, o) => jfetch('/api/read' + p, o);
|
||||
const rdJson = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const RD_LIBS = { epub: ['jszip.min.js', 'epub.min.js'], pdf: ['pdf.min.js'], docx: ['mammoth.browser.min.js'] };
|
||||
const RD_LIBS = { epub: ['jszip.min.js', 'epub.min.js'], pdf: ['pdf.min.js'], docx: ['mammoth.browser.min.js'], mobi: ['mobi-parser.min.js'] };
|
||||
const rdKind = ext => ['mobi', 'azw', 'azw3'].includes(ext) ? 'mobi' : ext;
|
||||
const rdLoaded = {};
|
||||
async function rdLibs(kind) {
|
||||
for (const f of RD_LIBS[kind]) {
|
||||
@@ -43,8 +44,8 @@ async function rdLibrary() {
|
||||
const cover = b => `<span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${b.id}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><span class="rdext">${esc(b.ext)}</span>${b.pct > 0 ? `<u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u>` : ''}</span>`;
|
||||
const card = b => `<a class="vcard" href="#/read/open/${b.id}" data-id="${b.id}">${cover(b)}<b>${esc(b.title)}</b><small>${esc(b.author || b.ext.toUpperCase())}${b.pct > 0 ? ' · ' + Math.round(b.pct * 100) + '%' : ''}</small></a>`;
|
||||
v.innerHTML = `<div class="wrap" id="rdlib"><div class="ptop"><h1>Books</h1><span class="pspace grow"></span><button class="btn" id="rdadd">${icon('plus')}Add books</button></div>
|
||||
<input type="file" id="rdfile" accept=".epub,.pdf,.docx" multiple hidden>
|
||||
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF and DOCX. Add files with the button or drop them on this page. Your place, bookmarks and highlights follow you to every device.</p></div>`;
|
||||
<input type="file" id="rdfile" accept=".epub,.pdf,.docx,.mobi,.azw,.azw3" multiple hidden>
|
||||
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF, DOCX, MOBI and AZW3 (without DRM). Add files with the button or drop them on this page. Your place, bookmarks and highlights follow you to every device.</p></div>`;
|
||||
const draw = () => {
|
||||
const f = q.trim().toLowerCase(), mine = books.filter(b => !f || (b.title + ' ' + b.author).toLowerCase().includes(f));
|
||||
const sorted = [...mine].sort(sort === 'title' ? (a, b) => a.title.localeCompare(b.title) : sort === 'author' ? (a, b) => (a.author || '~').localeCompare(b.author || '~') || a.title.localeCompare(b.title) : sort === 'added' ? (a, b) => b.added - a.added : (a, b) => (b.read || b.added) - (a.read || a.added));
|
||||
@@ -94,14 +95,14 @@ function rdEditBook(b) {
|
||||
|
||||
// ---- Adding books: the file goes up as it is; the browser reads the title, author and cover out of it afterwards.
|
||||
async function rdAddAll(files) {
|
||||
files = files.filter(f => /\.(epub|pdf|docx)$/i.test(f.name));
|
||||
if (!files.length) return toast('Choose EPUB, PDF or DOCX files');
|
||||
let added = 0, failed = 0;
|
||||
files = files.filter(f => /\.(epub|pdf|docx|mobi|azw3?)$/i.test(f.name));
|
||||
if (!files.length) return toast('Choose EPUB, PDF, DOCX, MOBI or AZW3 files');
|
||||
let added = 0, failed = 0, why = '';
|
||||
for (const [i, f] of files.entries()) {
|
||||
toast(`Adding ${i + 1} of ${files.length}: ${f.name}`);
|
||||
try { if (!(await rdAddOne(f)).duplicate) added++; } catch (e) { failed++; toast(f.name + ': ' + e.message); await new Promise(r => setTimeout(r, 1800)); }
|
||||
try { if (!(await rdAddOne(f)).duplicate) added++; } catch (e) { failed++; why = f.name + ': ' + e.message; toast(why); await new Promise(r => setTimeout(r, 1800)); }
|
||||
}
|
||||
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? 'Nothing was added' : 'Already in your library');
|
||||
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? (files.length === 1 ? why : 'Nothing was added') : 'Already in your library');
|
||||
if (location.hash.startsWith('#/read') && !location.hash.startsWith('#/read/open')) route();
|
||||
}
|
||||
async function rdAddOne(file) {
|
||||
@@ -134,8 +135,18 @@ async function rdMeta(ext, buf) {
|
||||
} finally { pdf.destroy(); }
|
||||
return out;
|
||||
}
|
||||
if (rdKind(ext) === 'mobi') {
|
||||
await rdLibs('mobi'); const m = await rdMobiOpen(new Uint8Array(buf.slice(0))), out = {};
|
||||
try { const md = m.getMetadata(); out.title = md.title; out.author = (md.author || []).join(', '); const cu = m.getCoverImage(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; } } finally { try { m.destroy(); } catch {} }
|
||||
return out;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
/** MOBI 6 and KF8 (AZW3) need different readers: try the newer format first, since the older reader can "open" a KF8 file and show nonsense. */
|
||||
async function rdMobiOpen(u8) {
|
||||
try { const k = await MobiParser.initKf8File(u8); if (k.getSpine().length) return k; try { k.destroy(); } catch {} } catch {}
|
||||
return MobiParser.initMobiFile(u8);
|
||||
}
|
||||
|
||||
// ======================================================= Reader
|
||||
let rdCur = null; // the open book's reader, so leaving the page can close it
|
||||
@@ -169,8 +180,8 @@ class RdReader {
|
||||
this.root = $('#rdroot'); this.applyChrome();
|
||||
this.hookUi();
|
||||
try {
|
||||
await rdLibs(b.ext);
|
||||
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : new RdDocx(this);
|
||||
await rdLibs(rdKind(b.ext));
|
||||
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : rdKind(b.ext) === 'mobi' ? new RdMobi(this) : new RdDocx(this);
|
||||
await this.A.open(this.$('#rdstage'), this.loc);
|
||||
this.$('#rdload').remove();
|
||||
} catch (e) { if (this.dead) return; this.$('#rdload').innerHTML = `<span class="err">${esc(e.message || 'This book could not be opened')}</span><br><a class="btn ghost" href="#/read" style="margin-top:12px">Back to library</a>`; }
|
||||
@@ -514,12 +525,15 @@ class RdPdf {
|
||||
// ======================================================= DOCX (converted to HTML in the browser; shown in a frame that cannot run scripts)
|
||||
class RdDocx {
|
||||
constructor(R) { this.R = R; }
|
||||
async convert() {
|
||||
const buf = await rdFetchBook(this.R.book.id, 'docx');
|
||||
return { html: (await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] })).value, head: '' };
|
||||
}
|
||||
async open(stage, loc) {
|
||||
const R = this.R, buf = await rdFetchBook(R.book.id, 'docx');
|
||||
const res = await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] });
|
||||
const R = this.R, res = await this.convert();
|
||||
this.f = document.createElement('iframe'); this.f.setAttribute('sandbox', 'allow-same-origin'); this.f.title = R.book.title; this.f.className = 'rdframe';
|
||||
stage.appendChild(this.f);
|
||||
this.f.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><style id="base">body{max-width:46em;margin:0 auto;padding:1.2em 6%;overflow-wrap:break-word}table{border-collapse:collapse}td,th{border:1px solid #8888;padding:.3em .5em}h1,h2,h3{line-height:1.25}mark.rdh{border-radius:2px;color:inherit;padding:0}mark.rdh.flash{outline:2px solid #e8590c}mark.rdq{background:#ffd43b88;color:inherit}</style><style id="theme"></style></head><body><article>${res.value}</article></body></html>`;
|
||||
this.f.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><style id="base">body{max-width:46em;margin:0 auto;padding:1.2em 6%;overflow-wrap:break-word}table{border-collapse:collapse}td,th{border:1px solid #8888;padding:.3em .5em}h1,h2,h3{line-height:1.25}mark.rdh{border-radius:2px;color:inherit;padding:0}mark.rdh.flash{outline:2px solid #e8590c}mark.rdq{background:#ffd43b88;color:inherit}</style><style id="theme"></style>${res.head}</head><body><article>${res.html}</article></body></html>`;
|
||||
await new Promise(ok => { this.f.onload = ok; });
|
||||
this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement;
|
||||
this.restyle();
|
||||
@@ -527,12 +541,13 @@ class RdDocx {
|
||||
this.d.addEventListener('mousedown', () => R.hideSel());
|
||||
const sel = () => setTimeout(() => this.checkSel(), 30);
|
||||
this.d.addEventListener('mouseup', sel); this.d.addEventListener('touchend', sel); this.d.addEventListener('keyup', sel);
|
||||
this.d.addEventListener('click', e => { const s = this.w.getSelection(); if (s && !s.isCollapsed) return; if (e.target.closest('a[href]')) { e.preventDefault(); const a = e.target.closest('a[href]'); if (a.getAttribute('href').startsWith('#')) this.d.getElementById(a.getAttribute('href').slice(1))?.scrollIntoView(); return; } R.toggleBars(); });
|
||||
this.d.addEventListener('click', e => { const s = this.w.getSelection(); if (s && !s.isCollapsed) return; if (e.target.closest('a[href]')) { e.preventDefault(); const a = e.target.closest('a[href]'); if (!this.link(a)) this.d.getElementById((a.getAttribute('href') || '').replace(/^#/, ''))?.scrollIntoView(); return; } R.toggleBars(); });
|
||||
this.d.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||||
this.w.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||||
for (const n of R.notes) this.drawNote(n);
|
||||
const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report();
|
||||
}
|
||||
link() { return false; }
|
||||
scroller() { return this.d.scrollingElement || this.root; }
|
||||
restyle(k) {
|
||||
const R = this.R, t = rdTheme(R.P); this.d.getElementById('theme').textContent = rdCss(R.P, t); R.root.style.setProperty('--rd-bg', t.bg);
|
||||
@@ -577,3 +592,29 @@ class RdDocx {
|
||||
resize() {}
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
// ======================================================= MOBI / AZW / AZW3 (DRM-free): every chapter is joined into one page, then read like a DOCX
|
||||
class RdMobi extends RdDocx {
|
||||
async convert() {
|
||||
const buf = new Uint8Array(await rdFetchBook(this.R.book.id, 'mobi'));
|
||||
this.m = await rdMobiOpen(buf); const spine = this.m.getSpine(), css = new Set(), parts = [];
|
||||
for (const ch of spine) { const c = this.m.loadChapter(ch.id); if (!c) continue; (c.css || []).forEach(x => css.add(x.href)); parts.push(`<section class="rdch" id="rdch-${esc(ch.id)}" data-ch="${esc(ch.id)}">${c.html}</section>`); }
|
||||
if (!parts.length) throw new Error('This book has no readable text. It may have DRM.');
|
||||
return { html: parts.join(''), head: [...css].map(h => `<link rel="stylesheet" href="${esc(h)}">`).join('') };
|
||||
}
|
||||
/** a link inside the book: the parser says which chapter (and where in it) it points to */
|
||||
link(a) {
|
||||
const href = a.getAttribute('href') || ''; if (/^(https?:|mailto:)/i.test(href)) { window.open(href, '_blank', 'noopener'); return true; }
|
||||
let r; try { r = this.m.resolveHref(href); } catch {} if (!r) return false;
|
||||
const sec = this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} }
|
||||
if (el) { el.scrollIntoView(); return true; } return false;
|
||||
}
|
||||
async toc() {
|
||||
const out = [], walk = (items, d) => (items || []).forEach(t => {
|
||||
let loc = null; try { const r = this.m.resolveHref(t.href); const sec = r && this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} } if (el) { const w = this.d.createTreeWalker(el, NodeFilter.SHOW_TEXT), first = w.nextNode(); if (first) loc = 'o:' + this.offsetTo(first, 0); } } catch {}
|
||||
if (loc) out.push({ label: String(t.label || '').trim().slice(0, 120) || 'Untitled', loc, depth: d }); walk(t.children, d + 1); });
|
||||
walk(this.m.getToc(), 0);
|
||||
return out.length ? out : super.toc();
|
||||
}
|
||||
destroy() { try { this.m && this.m.destroy(); } catch {} }
|
||||
}
|
||||
Reference in new issue
Block a user