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:
Claude committed 2026-10-24 02:54:34 +00:00
1 parent 07c16ddc25
commit 2e99893c8b
7 files changed
+138 -21

No files matched your search

+3 -3
View File
@@ -56,10 +56,10 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET` script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON (and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied. `{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
- **Books**: an e-book reader for **EPUB, PDF and DOCX** (Media group; give it to people in Settings -> People as *Books*). Add files with the button or by dropping them on the page (up to 300 MB each; the server checks a file really is what its name says and does not store the same book twice). Titles, authors and covers are read out of the books in the browser; long-press or right-click a book to edit, download or delete it. Each person has their own shelf in `/data/users/<id>/books/`. - **Books**: an e-book reader for **EPUB, PDF, DOCX, MOBI, AZW and AZW3** (Media group; give it to people in Settings -> People as *Books*). Add files with the button or by dropping them on the page (up to 300 MB each; the server checks a file really is what its name says and does not store the same book twice). Titles, authors and covers are read out of the books in the browser; long-press or right-click a book to edit, download or delete it. Each person has their own shelf in `/data/users/<id>/books/`.
- **Reading**: EPUB uses epub.js (pages or scroll, two-page spreads on wide screens), PDF uses pdf.js (continuous scroll, zoom, selectable text, dark and sepia tints) and DOCX is converted to HTML by mammoth and shown in a frame that cannot run scripts (scroll only). Contents, search inside the book, a progress slider, tap the edges or use the arrow keys, swipe on touch screens, tap the middle to hide the bars. Appearance (theme, text size, font, line spacing, margins, layout, zoom) is remembered on each device. - **Reading**: EPUB uses epub.js (pages or scroll, two-page spreads on wide screens), PDF uses pdf.js (continuous scroll, zoom, selectable text, dark and sepia tints) DOCX is converted to HTML by mammoth, and MOBI, AZW and AZW3 are read by `@lingo-reader/mobi-parser`; both are shown in a frame that cannot run scripts (scroll only, with contents from the book or its headings). Contents, search inside the book, a progress slider, tap the edges or use the arrow keys, swipe on touch screens, tap the middle to hide the bars. Appearance (theme, text size, font, line spacing, margins, layout, zoom) is remembered on each device.
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes. - **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB and PDF to try. Not included: DRM-protected books, MOBI/AZW, text-to-speech, offline reading. - The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't - **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
+7 -2
View File
@@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path'), crypto = require('crypto');
const users = require('./users'); const users = require('./users');
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_JSON = 2e6; const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_JSON = 2e6;
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }; const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw: 'application/vnd.amazon.ebook', azw3: 'application/vnd.amazon.mobi8-ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
const COVERS = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' }; const COVERS = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' };
const dir = () => { const d = path.join(users.dir(), 'books'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; }; const dir = () => { const d = path.join(users.dir(), 'books'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
@@ -23,9 +23,13 @@ async function body(req, max) {
for await (const c of req) { n += c.length; if (n > max) return null; chunks.push(c); } for await (const c of req) { n += c.length; if (n > max) return null; chunks.push(c); }
return Buffer.concat(chunks); return Buffer.concat(chunks);
} }
const MOBI = new Set(['mobi', 'azw', 'azw3']);
/** Kindle files with DRM have a non-zero encryption type in their first record (the PalmDOC header, 12 bytes in). Those cannot be read and are refused. */
function mobiLocked(buf) { try { const rec0 = buf.readUInt32BE(78); return buf.readUInt16BE(rec0 + 12) !== 0; } catch { return true; } }
/** A file must look like what its name says: PDFs start with %PDF, EPUB and DOCX are zip archives (and an EPUB says so in its first entry). */ /** A file must look like what its name says: PDFs start with %PDF, EPUB and DOCX are zip archives (and an EPUB says so in its first entry). */
function looksLike(ext, buf) { function looksLike(ext, buf) {
if (ext === 'pdf') return buf.subarray(0, 1024).includes('%PDF-'); if (ext === 'pdf') return buf.subarray(0, 1024).includes('%PDF-');
if (MOBI.has(ext)) return buf.length > 100 && buf.toString('latin1', 60, 68) === 'BOOKMOBI';
if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) return false; if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) return false;
return ext === 'epub' ? buf.subarray(0, 200).includes('mimetypeapplication/epub+zip') || buf.includes('META-INF/container.xml') : buf.includes('word/document.xml'); return ext === 'epub' ? buf.subarray(0, 200).includes('mimetypeapplication/epub+zip') || buf.includes('META-INF/container.xml') : buf.includes('word/document.xml');
} }
@@ -51,9 +55,10 @@ async function handle(req, res) {
if (p === '/upload' && req.method === 'POST') { if (p === '/upload' && req.method === 'POST') {
const name = clip(u.searchParams.get('name'), 200), ext = path.extname(name).slice(1).toLowerCase(); const name = clip(u.searchParams.get('name'), 200), ext = path.extname(name).slice(1).toLowerCase();
if (!TYPES[ext]) return json(res, 400, { error: 'Only EPUB, PDF and DOCX books can be added' }); if (!TYPES[ext]) return json(res, 400, { error: 'Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added' });
const buf = await body(req, MAX_BOOK); if (!buf) return json(res, 413, { error: 'That file is larger than 300 MB' }); const buf = await body(req, MAX_BOOK); if (!buf) return json(res, 413, { error: 'That file is larger than 300 MB' });
if (!looksLike(ext, buf)) return json(res, 400, { error: 'That does not look like a real ' + ext.toUpperCase() + ' file' }); if (!looksLike(ext, buf)) return json(res, 400, { error: 'That does not look like a real ' + ext.toUpperCase() + ' file' });
if (MOBI.has(ext) && mobiLocked(buf)) return json(res, 400, { error: 'This book has DRM (copy protection), which NE Hub cannot open. Only DRM-free books work.' });
const hash = crypto.createHash('sha1').update(buf).digest('hex'), list = index(), dup = list.find(b => b.hash === hash); const hash = crypto.createHash('sha1').update(buf).digest('hex'), list = index(), dup = list.find(b => b.hash === hash);
if (dup) return json(res, 200, { ...dup, duplicate: true }); if (dup) return json(res, 200, { ...dup, duplicate: true });
const id = crypto.randomBytes(8).toString('hex'); const id = crypto.randomBytes(8).toString('hex');
+55 -14
View File
@@ -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. // 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 rdApi = (p, o) => jfetch('/api/read' + p, o);
const rdJson = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(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 = {}; const rdLoaded = {};
async function rdLibs(kind) { async function rdLibs(kind) {
for (const f of RD_LIBS[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 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>`; 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> 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> <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 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>`; <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 draw = () => {
const f = q.trim().toLowerCase(), mine = books.filter(b => !f || (b.title + ' ' + b.author).toLowerCase().includes(f)); 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)); 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. // ---- 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) { async function rdAddAll(files) {
files = files.filter(f => /\.(epub|pdf|docx)$/i.test(f.name)); files = files.filter(f => /\.(epub|pdf|docx|mobi|azw3?)$/i.test(f.name));
if (!files.length) return toast('Choose EPUB, PDF or DOCX files'); if (!files.length) return toast('Choose EPUB, PDF, DOCX, MOBI or AZW3 files');
let added = 0, failed = 0; let added = 0, failed = 0, why = '';
for (const [i, f] of files.entries()) { for (const [i, f] of files.entries()) {
toast(`Adding ${i + 1} of ${files.length}: ${f.name}`); 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(); if (location.hash.startsWith('#/read') && !location.hash.startsWith('#/read/open')) route();
} }
async function rdAddOne(file) { async function rdAddOne(file) {
@@ -134,8 +135,18 @@ async function rdMeta(ext, buf) {
} finally { pdf.destroy(); } } finally { pdf.destroy(); }
return out; 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 {}; 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 // ======================================================= Reader
let rdCur = null; // the open book's reader, so leaving the page can close it 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.root = $('#rdroot'); this.applyChrome();
this.hookUi(); this.hookUi();
try { try {
await rdLibs(b.ext); await rdLibs(rdKind(b.ext));
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : new RdDocx(this); 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); await this.A.open(this.$('#rdstage'), this.loc);
this.$('#rdload').remove(); 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>`; } } 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) // ======================================================= DOCX (converted to HTML in the browser; shown in a frame that cannot run scripts)
class RdDocx { class RdDocx {
constructor(R) { this.R = R; } 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) { async open(stage, loc) {
const R = this.R, buf = await rdFetchBook(R.book.id, 'docx'); const R = this.R, res = await this.convert();
const res = await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] });
this.f = document.createElement('iframe'); this.f.setAttribute('sandbox', 'allow-same-origin'); this.f.title = R.book.title; this.f.className = 'rdframe'; 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); 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; }); await new Promise(ok => { this.f.onload = ok; });
this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement; this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement;
this.restyle(); this.restyle();
@@ -527,12 +541,13 @@ class RdDocx {
this.d.addEventListener('mousedown', () => R.hideSel()); this.d.addEventListener('mousedown', () => R.hideSel());
const sel = () => setTimeout(() => this.checkSel(), 30); const sel = () => setTimeout(() => this.checkSel(), 30);
this.d.addEventListener('mouseup', sel); this.d.addEventListener('touchend', sel); this.d.addEventListener('keyup', sel); 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.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 }); 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); for (const n of R.notes) this.drawNote(n);
const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report(); const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report();
} }
link() { return false; }
scroller() { return this.d.scrollingElement || this.root; } scroller() { return this.d.scrollingElement || this.root; }
restyle(k) { 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); 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() {} resize() {}
destroy() {} 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 {} }
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 hhk-png
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
var MobiParser=(()=>{var Ut=Object.create;var Se=Object.defineProperty;var Mt=Object.getOwnPropertyDescriptor;var zt=Object.getOwnPropertyNames;var kt=Object.getPrototypeOf,Ht=Object.prototype.hasOwnProperty;var Gt=(r=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(r,{get:(e,t)=>(typeof require!="undefined"?require:e)[t]}):r)(function(r){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+r+'" is not supported')});var Me=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}},jt=(r,e)=>{for(var t in e)Se(r,t,{get:e[t],enumerable:!0})},Ze=(r,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of zt(e))!Ht.call(r,o)&&o!==t&&Se(r,o,{get:()=>e[o],enumerable:!(s=Mt(e,o))||s.enumerable});return r};var ze=(r,e,t)=>(t=r!=null?Ut(kt(r)):{},Ze(e||!r||!r.__esModule?Se(t,"default",{value:r,enumerable:!0}):t,r)),Vt=r=>Ze(Se({},"__esModule",{value:!0}),r);var at=Me((sr,ot)=>{"use strict";function ie(r){if(typeof r!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(r))}function st(r,e){for(var t="",s=0,o=-1,l=0,a,u=0;u<=r.length;++u){if(u<r.length)a=r.charCodeAt(u);else{if(a===47)break;a=47}if(a===47){if(!(o===u-1||l===1))if(o!==u-1&&l===2){if(t.length<2||s!==2||t.charCodeAt(t.length-1)!==46||t.charCodeAt(t.length-2)!==46){if(t.length>2){var d=t.lastIndexOf("/");if(d!==t.length-1){d===-1?(t="",s=0):(t=t.slice(0,d),s=t.length-1-t.lastIndexOf("/")),o=u,l=0;continue}}else if(t.length===2||t.length===1){t="",s=0,o=u,l=0;continue}}e&&(t.length>0?t+="/..":t="..",s=2)}else t.length>0?t+="/"+r.slice(o+1,u):t=r.slice(o+1,u),s=u-o-1;o=u,l=0}else a===46&&l!==-1?++l:l=-1}return t}function on(r,e){var t=e.dir||e.root,s=e.base||(e.name||"")+(e.ext||"");return t?t===e.root?t+s:t+r+s:s}var he={resolve:function(){for(var e="",t=!1,s,o=arguments.length-1;o>=-1&&!t;o--){var l;o>=0?l=arguments[o]:(s===void 0&&(s=process.cwd()),l=s),ie(l),l.length!==0&&(e=l+"/"+e,t=l.charCodeAt(0)===47)}return e=st(e,!t),t?e.length>0?"/"+e:"/":e.length>0?e:"."},normalize:function(e){if(ie(e),e.length===0)return".";var t=e.charCodeAt(0)===47,s=e.charCodeAt(e.length-1)===47;return e=st(e,!t),e.length===0&&!t&&(e="."),e.length>0&&s&&(e+="/"),t?"/"+e:e},isAbsolute:function(e){return ie(e),e.length>0&&e.charCodeAt(0)===47},join:function(){if(arguments.length===0)return".";for(var e,t=0;t<arguments.length;++t){var s=arguments[t];ie(s),s.length>0&&(e===void 0?e=s:e+="/"+s)}return e===void 0?".":he.normalize(e)},relative:function(e,t){if(ie(e),ie(t),e===t||(e=he.resolve(e),t=he.resolve(t),e===t))return"";for(var s=1;s<e.length&&e.charCodeAt(s)===47;++s);for(var o=e.length,l=o-s,a=1;a<t.length&&t.charCodeAt(a)===47;++a);for(var u=t.length,d=u-a,m=l<d?l:d,v=-1,g=0;g<=m;++g){if(g===m){if(d>m){if(t.charCodeAt(a+g)===47)return t.slice(a+g+1);if(g===0)return t.slice(a+g)}else l>m&&(e.charCodeAt(s+g)===47?v=g:g===0&&(v=0));break}var p=e.charCodeAt(s+g),y=t.charCodeAt(a+g);if(p!==y)break;p===47&&(v=g)}var b="";for(g=s+v+1;g<=o;++g)(g===o||e.charCodeAt(g)===47)&&(b.length===0?b+="..":b+="/..");return b.length>0?b+t.slice(a+v):(a+=v,t.charCodeAt(a)===47&&++a,t.slice(a))},_makeLong:function(e){return e},dirname:function(e){if(ie(e),e.length===0)return".";for(var t=e.charCodeAt(0),s=t===47,o=-1,l=!0,a=e.length-1;a>=1;--a)if(t=e.charCodeAt(a),t===47){if(!l){o=a;break}}else l=!1;return o===-1?s?"/":".":s&&o===1?"//":e.slice(0,o)},basename:function(e,t){if(t!==void 0&&typeof t!="string")throw new TypeError('"ext" argument must be a string');ie(e);var s=0,o=-1,l=!0,a;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return"";var u=t.length-1,d=-1;for(a=e.length-1;a>=0;--a){var m=e.charCodeAt(a);if(m===47){if(!l){s=a+1;break}}else d===-1&&(l=!1,d=a+1),u>=0&&(m===t.charCodeAt(u)?--u===-1&&(o=a):(u=-1,o=d))}return s===o?o=d:o===-1&&(o=e.length),e.slice(s,o)}else{for(a=e.length-1;a>=0;--a)if(e.charCodeAt(a)===47){if(!l){s=a+1;break}}else o===-1&&(l=!1,o=a+1);return o===-1?"":e.slice(s,o)}},extname:function(e){ie(e);for(var t=-1,s=0,o=-1,l=!0,a=0,u=e.length-1;u>=0;--u){var d=e.charCodeAt(u);if(d===47){if(!l){s=u+1;break}continue}o===-1&&(l=!1,o=u+1),d===46?t===-1?t=u:a!==1&&(a=1):t!==-1&&(a=-1)}return t===-1||o===-1||a===0||a===1&&t===o-1&&t===s+1?"":e.slice(t,o)},format:function(e){if(e===null||typeof e!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof e);return on("/",e)},parse:function(e){ie(e);var t={root:"",dir:"",base:"",ext:"",name:""};if(e.length===0)return t;var s=e.charCodeAt(0),o=s===47,l;o?(t.root="/",l=1):l=0;for(var a=-1,u=0,d=-1,m=!0,v=e.length-1,g=0;v>=l;--v){if(s=e.charCodeAt(v),s===47){if(!m){u=v+1;break}continue}d===-1&&(m=!1,d=v+1),s===46?a===-1?a=v:g!==1&&(g=1):a!==-1&&(g=-1)}return a===-1||d===-1||g===0||g===1&&a===d-1&&a===u+1?d!==-1&&(u===0&&o?t.base=t.name=e.slice(1,d):t.base=t.name=e.slice(u,d)):(u===0&&o?(t.name=e.slice(1,a),t.baseLine truncated
`||i==="\r"||i===" "}function R(i){return i==='"'||i==="'"}function X(i){return i===">"||I(i)}function F(i,n){return i.test(n)}function k(i,n){return!F(i,n)}var f=0;r.STATE={BEGIN:f++,BEGIN_WHITESPACE:f++,TEXT:f++,TEXT_ENTITY:f++,OPEN_WAKA:f++,SGML_DECL:f++,SGML_DECL_QUOTED:f++,DOCTYPE:f++,DOCTYPE_QUOTED:f++,DOCTYPE_DTD:f++,DOCTYPE_DTD_QUOTED:f++,COMMENT_STARTING:f++,COMMENT:f++,COMMENT_ENDING:f++,COMMENT_ENDED:f++,CDATA:f++,CDATA_ENDING:f++,CDATA_ENDING_2:f++,PROC_INST:f++,PROC_INST_BODY:f++,PROC_INST_ENDING:f++,OPEN_TAG:f++,OPEN_TAG_SLASH:f++,ATTRIB:f++,ATTRIB_NAME:f++,ATTRIB_NAME_SAW_WHITE:f++,ATTRIB_VALUE:f++,ATTRIB_VALUE_QUOTED:f++,ATTRIB_VALUE_CLOSED:f++,ATTRIB_VALUE_UNQUOTED:f++,ATTRIB_VALUE_ENTITY_Q:f++,ATTRIB_VALUE_ENTITY_U:f++,CLOSE_TAG:f++,CLOSE_TAG_SAW_WHITE:f++,SCRIPT:f++,SCRIPT_ENDING:f++},r.XML_ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'"}),r.ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'",AElig:198,Aacute:193,Acirc:194,Agrave:192,Aring:197,Atilde:195,Auml:196,Ccedil:199,ETH:208,Eacute:201,Ecirc:202,Egrave:200,Euml:203,Iacute:205,Icirc:206,Igrave:204,Iuml:207,Ntilde:209,Oacute:211,Ocirc:212,Ograve:210,Oslash:216,Otilde:213,Ouml:214,THORN:222,Uacute:218,Ucirc:219,Ugrave:217,Uuml:220,Yacute:221,aacute:225,acirc:226,aelig:230,agrave:224,aring:229,atilde:227,auml:228,ccedil:231,eacute:233,ecirc:234,egrave:232,eth:240,euml:235,iacute:237,icirc:238,igrave:236,iuml:239,ntilde:241,oacute:243,ocirc:244,ograve:242,oslash:248,otilde:245,ouml:246,szlig:223,thorn:254,uacute:250,ucirc:251,ugrave:249,uuml:252,yacute:253,yuml:255,copy:169,reg:174,nbsp:160,iexcl:161,cent:162,pound:163,curren:164,yen:165,brvbar:166,sect:167,uml:168,ordf:170,laquo:171,not:172,shy:173,macr:175,deg:176,plusmn:177,sup1:185,sup2:178,sup3:179,acute:180,micro:181,para:182,middot:183,cedil:184,ordm:186,raquo:187,frac14:188,frac12:189,frac34:190,iquest:191,times:215,divide:247,OElig:338,oelig:339,Scaron:352,scaron:353,Yuml:376,fnof:402,circ:710,tilde:732,Alpha:913,Beta:914,Gamma:915,Delta:916,Epsilon:917,Zeta:918,Eta:919,Theta:920,Iota:921,Kappa:922,Lambda:923,Mu:924,Nu:925,Xi:926,Omicron:927,Pi:928,Rho:929,Sigma:931,Tau:932,Upsilon:933,Phi:934,Chi:935,Psi:936,Omega:937,alpha:945,beta:946,gamma:947,delta:948,epsilon:949,zeta:950,eta:951,theta:952,iota:953,kappa:954,lambda:955,mu:956,nu:957,xi:958,omicron:959,pi:960,rho:961,sigmaf:962,sigma:963,tau:964,upsilon:965,phi:966,chi:967,psi:968,omega:969,thetasym:977,upsih:978,piv:982,ensp:8194,emsp:8195,thinsp:8201,zwnj:8204,zwj:8205,lrm:8206,rlm:8207,ndash:8211,mdash:8212,lsquo:8216,rsquo:8217,sbquo:8218,ldquo:8220,rdquo:8221,bdquo:8222,dagger:8224,Dagger:8225,bull:8226,hellip:8230,permil:8240,prime:8242,Prime:8243,lsaquo:8249,rsaquo:8250,oline:8254,frasl:8260,euro:8364,image:8465,weierp:8472,real:8476,trade:8482,alefsym:8501,larr:8592,uarr:8593,rarr:8594,darr:8595,harr:8596,crarr:8629,lArr:8656,uArr:8657,rArr:8658,dArr:8659,hArr:8660,forall:8704,part:8706,exist:8707,empty:8709,nabla:8711,isin:8712,notin:8713,ni:8715,prod:8719,sum:8721,minus:8722,lowast:8727,radic:8730,prop:8733,infin:8734,ang:8736,and:8743,or:8744,cap:8745,cup:8746,int:8747,there4:8756,sim:8764,cong:8773,asymp:8776,ne:8800,equiv:8801,le:8804,ge:8805,sub:8834,sup:8835,nsub:8836,sube:8838,supe:8839,oplus:8853,otimes:8855,perp:8869,sdot:8901,lceil:8968,rceil:8969,lfloor:8970,rfloor:8971,lang:9001,rang:9002,loz:9674,spades:9824,clubs:9827,hearts:9829,diams:9830}),Object.keys(r.ENTITIES).forEach(function(i){var n=r.ENTITIES[i],h=typeof n=="number"?String.fromCharCode(n):n;r.ENTITIES[i]=h});for(var W in r.STATE)r.STATE[r.STATE[W]]=W;f=r.STATE;function _(i,n,h){i[n]&&i[n](h)}function J(i){var n=i&&i.match(/(?:^|\s)encoding\s*=\s*(['"])([^'"]+)\1/i);return n?n[2]:null}function ce(i){return i?i.toLowerCase().replace(/[^a-z0-9]/g,""):null}function ne(i,n){let h=ce(i),c=ce(n);return!h||!c?!0:c==="utf16"?h==="utf16le"||h==="utf16be":h===c}function Y(i,n){if(!(!i.strict||!i.encoding||!n||n.name!=="xml")){var h=J(n.body);h&&!ne(i.encoding,h)&&N(i,"XML declaration encoding "+h+" does not match detected stream encoding "+i.encoding.toUpperCase())}}function A(i,n,h){i.textNode&&U(i),_(i,n,h)}function U(i){i.textNode=pe(i.opt,i.textNode),i.textNode&&_(i,"ontext",i.textNode),i.textNode=""}function pe(i,n){return i.trim&&(n=n.trim()),i.normalize&&(n=n.replace(/\s+/g," ")),n}function z(i,n){return U(i),i.trackPosition&&(n+=`
Line: `+i.line+`
Column: `+i.column+`
Char: `+i.c),n=new Error(n),i.error=n,_(i,"onerror",n),i}function xe(i){return i.sawRoot&&!i.closedRoot&&N(i,"Unclosed root tag"),i.state!==f.BEGIN&&i.state!==f.BEGIN_WHITESPACE&&i.state!==f.TEXT&&z(i,"Unexpected end"),U(i),i.c="",i.closed=!0,_(i,"onend"),t.call(i,i.strict,i.opt),i}function N(i,n){if(typeof i!="object"||!(i instanceof t))throw new Error("bad call to strictFail");i.strict&&z(i,n)}function me(i){i.strict||(i.tagName=i.tagName[i.looseCase]());var n=i.tags[i.tags.length-1]||i,h=i.tag={name:i.tagName,attributes:{}};i.opt.xmlns&&(h.ns=n.ns),i.attribList.length=0,A(i,"onopentagstart",h)}function re(i,n){var h=i.indexOf(":"),c=h<0?["",i]:i.split(":"),S=c[0],L=c[1];return n&&i==="xmlns"&&(S="xmlns",L=""),{prefix:S,local:L}}function fe(i){if(i.strict||(i.attribName=i.attribName[i.looseCase]()),i.attribList.indexOf(i.attribName)!==-1||i.tag.attributes.hasOwnProperty(i.attribName)){i.attribName=i.attribValue="";return}if(i.opt.xmlns){var n=re(i.attribName,!0),h=n.prefix,c=n.local;if(h==="xmlns")if(c==="xml"&&i.attribValue!==y)N(i,"xml: prefix must be bound to "+y+`
Actual: `+i.attribValue);else if(c==="xmlns"&&i.attribValue!==b)N(i,"xmlns: prefix must be bound to "+b+`
Actual: `+i.attribValue);else{var S=i.tag,L=i.tags[i.tags.length-1]||i;S.ns===L.ns&&(S.ns=Object.create(L.ns)),S.ns[c]=i.attribValue}i.attribList.push([i.attribName,i.attribValue])}else i.tag.attributes[i.attribName]=i.attribValue,A(i,"onattribute",{name:i.attribName,value:i.attribValue});i.attribName=i.attribValue=""}function q(i,n){if(i.opt.xmlns){var h=i.tag,c=re(i.tagName);h.prefix=c.prefix,h.local=c.local,h.uri=h.ns[c.prefix]||"",h.prefix&&!h.uri&&(N(i,"Unbound namespace prefix: "+JSON.stringify(i.tagName)),h.uri=c.prefix);var S=i.tags[i.tags.length-1]||i;h.ns&&S.ns!==h.ns&&Object.keys(h.ns).forEach(function($e){A(i,"onopennamespace",{prefix:$e,uri:h.ns[$e]})});for(var L=0,P=i.attribList.length;L<P;L++){var j=i.attribList[L],V=j[0],se=j[1],M=re(V,!0),oe=M.prefix,Bt=M.local,We=oe===""?"":h.ns[oe]||"",Ue={name:V,value:se,prefix:oe,local:Bt,uri:We};oe&&oe!=="xmlns"&&!We&&(N(i,"Unbound namespace prefix: "+JSON.stringify(oe)),Ue.uri=oe),i.tag.attributes[V]=Ue,A(i,"onattribute",Ue)}i.attribList.length=0}i.tag.isSelfClosing=!!n,i.sawRoot=!0,i.tags.push(i.tag),A(i,"onopentag",i.tag),n||(!i.noscript&&i.tagName.toLowerCase()==="script"?i.state=f.SCRIPT:i.state=f.TEXT,i.tag=null,i.tagName=""),i.attribName=i.attribValue="",i.attribList.length=0}function le(i){if(!i.tagName){N(i,"Weird empty close tag."),i.textNode+="</>",i.state=f.TEXT;return}if(i.script){if(i.tagName!=="script"){i.script+="</"+i.tagName+">",i.tagName="",i.state=f.SCRIPT;return}A(i,"onscript",i.script),i.script=""}var n=i.tags.length,h=i.tagName;i.strict||(h=h[i.looseCase]());for(var c=h;n--;){var S=i.tags[n];if(S.name!==c)N(i,"Unexpected close tag");else break}if(n<0){N(i,"Unmatched closing tag: "+i.tagName),i.textNode+="</"+i.tagName+">",i.state=f.TEXT;return}i.tagName=h;for(var L=i.tags.length;L-- >n;){var P=i.tag=i.tags.pop();i.tagName=i.tag.name,A(i,"onclosetag",i.tagName);var j={};for(var V in P.ns)j[V]=P.ns[V];var se=i.tags[i.tags.length-1]||i;i.opt.xmlns&&P.ns!==se.ns&&Object.keys(P.ns).forEach(function(M){var oe=P.ns[M];A(i,"onclosenamespace",{prefix:M,uri:oe})})}n===0&&(i.closedRoot=!0),i.tagName=i.attribValue=i.attribName="",i.attribList.length=0,i.state=f.TEXT}function ye(i){var n=i.entity,h=n.toLowerCase(),c,S="";return i.ENTITIES[n]?i.ENTITIES[n]:i.ENTITIES[h]?i.ENTITIES[h]:(n=h,n.charAt(0)==="#"&&(n.charAt(1)==="x"?(n=n.slice(2),c=parseInt(n,16),S=c.toString(16)):(n=n.slice(1),c=parseInt(n,10),S=c.toString(10))),n=n.replace(/^0+/,""),isNaN(c)||S.toLowerCase()!==n||c<0||c>1114111||!Ne(c)?(N(i,"Invalid character entity"),"&"+i.entity+";"):String.fromCodePoint(c))}function Ne(i){return i===9||i===10||i===13||i>=32&&i<=55295||i>=57344&&i<=65533||i>=65536&&i<=1114111}function Te(i,n){n==="<"?(i.state=f.OPEN_WAKA,i.startTagPosition=i.position):I(n)||(N(i,"Non-whitespace before first tag."),i.textNode=n,i.state=f.TEXT)}function be(i,n){var h="";return n<i.length&&(h=i.charAt(n)),h}function Ie(i){var n=this;if(this.error)throw this.error;if(n.closed)return z(n,"Cannot write after close. Assign an onready handler.");if(i===null)return xe(n);typeof i=="object"&&(i=i.toString());for(var h=0,c="";c=be(i,h++),n.c=c,!!c;)switch(n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++),n.state){case f.BEGIN:if(n.state=f.BEGIN_WHITESPACE,c==="\uFEFF")continue;Te(n,c);continue;case f.BEGIN_WHITESPACE:Te(n,c);continue;case f.TEXT:if(n.sawRoot&&!n.closedRoot){for(var L=h-1;c&&c!=="<"&&c!=="&";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++);n.textNode+=i.substring(L,h-1)}c==="<"&&!(n.sawRoot&&n.closedRoot&&!n.strict)?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):(!I(c)&&(!n.sawRoot||n.closedRoot)&&N(n,"Text data outside of root node."),c==="&"?n.state=f.TEXT_ENTITY:n.textNode+=c);continue;case f.SCRIPT:c==="<"?n.state=f.SCRIPT_ENDING:n.script+=c;continue;case f.SCRIPT_ENDING:c==="/"?n.state=f.CLOSE_TAG:(n.script+="<"+c,n.state=f.SCRIPT);continue;case f.OPEN_WAKA:if(c==="!")n.state=f.SGML_DECL,n.sgmlDecl="";else if(!I(c))if(F(w,c))n.state=f.OPEN_TAG,n.tagName=c;else if(c==="/")n.state=f.CLOSE_TAG,n.tagName="";else if(c==="?")n.state=f.PROC_INST,n.procInstName=n.procInstBody="";else{if(N(n,"Unencoded <"),n.startTagPosition+1<n.position){var S=n.position-n.startTagPosition;c=new Array(S).join(" ")+c}n.textNode+="<"+c,n.state=f.TEXT}continue;case f.SGML_DECL:if(n.sgmlDecl+c==="--"){n.state=f.COMMENT,n.comment="",n.sgmlDecl="";continue}n.doctype&&n.doctype!==!0&&n.sgmlDecl?(n.state=f.DOCTYPE_DTD,n.doctype+="<!"+n.sgmlDecl+c,n.sgmlDecl=""):g.test(n.sgmlDecl+c)?(A(n,"onopencdata"),n.state=f.CDATA,n.sgmlDecl="",n.cdata=""):p.test(n.sgmlDecl+c)?(n.state=f.DOCTYPE,(n.doctype||n.sawRoot)&&N(n,"Inappropriately located doctype declaration"),n.doctype="",n.sgmlDecl=""):c===">"?(A(n,"onsgmldeclaration",n.sgmlDecl),n.sgmlDecl="",n.state=f.TEXT):(R(c)&&(n.state=f.SGML_DECL_QUOTED),n.sgmlDecl+=c);continue;case f.SGML_DECL_QUOTED:c===n.q&&(n.state=f.SGML_DECL,n.q=""),n.sgmlDecl+=c;continue;case f.DOCTYPE:c===">"?(n.state=f.TEXT,A(n,"ondoctype",n.doctype),n.doctype=!0):(n.doctype+=c,c==="["?n.state=f.DOCTYPE_DTD:R(c)&&(n.state=f.DOCTYPE_QUOTED,n.q=c));continue;case f.DOCTYPE_QUOTED:n.doctype+=c,c===n.q&&(n.q="",n.state=f.DOCTYPE);continue;case f.DOCTYPE_DTD:c==="]"?(n.doctype+=c,n.state=f.DOCTYPE):c==="<"?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):R(c)?(n.doctype+=c,n.state=f.DOCTYPE_DTD_QUOTED,n.q=c):n.doctype+=c;continue;case f.DOCTYPE_DTD_QUOTED:n.doctype+=c,c===n.q&&(n.state=f.DOCTYPE_DTD,n.q="");continue;case f.COMMENT:c==="-"?n.state=f.COMMENT_ENDING:n.comment+=c;continue;case f.COMMENT_ENDING:c==="-"?(n.state=f.COMMENT_ENDED,n.comment=pe(n.opt,n.comment),n.comment&&A(n,"oncomment",n.comment),n.comment=""):(n.comment+="-"+c,n.state=f.COMMENT);continue;case f.COMMENT_ENDED:c!==">"?(N(n,"Malformed comment"),n.comment+="--"+c,n.state=f.COMMENT):n.doctype&&n.doctype!==!0?n.state=f.DOCTYPE_DTD:n.state=f.TEXT;continue;case f.CDATA:for(var L=h-1;c&&c!=="]";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++);n.cdata+=i.substring(L,h-1),c==="]"&&(n.state=f.CDATA_ENDING);continue;case f.CDATA_ENDING:c==="]"?n.state=f.CDATA_ENDING_2:(n.cdata+="]"+c,n.state=f.CDATA);continue;case f.CDATA_ENDING_2:c===">"?(n.cdata&&A(n,"oncdata",n.cdata),A(n,"onclosecdata"),n.cdata="",n.state=f.TEXT):c==="]"?n.cdata+="]":(n.cdata+="]]"+c,n.state=f.CDATA);continue;case f.PROC_INST:c==="?"?n.state=f.PROC_INST_ENDING:I(c)?n.state=f.PROC_INST_BODY:n.procInstName+=c;continue;case f.PROC_INST_BODY:if(!n.procInstBody&&I(c))continue;c==="?"?n.state=f.PROC_INST_ENDING:n.procInstBody+=c;continue;case f.PROC_INST_ENDING:if(c===">"){let se={name:n.procInstName,body:n.procInstBody};Y(n,se),A(n,"onprocessinginstruction",se),n.procInstName=n.procInstBody="",n.state=f.TEXT}else n.procInstBody+="?"+c,n.state=f.PROC_INST_BODY;continue;case f.OPEN_TAG:F(C,c)?n.tagName+=c:(me(n),c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:(I(c)||N(n,"Invalid character in tag name"),n.state=f.ATTRIB));continue;case f.OPEN_TAG_SLASH:c===">"?(q(n,!0),le(n)):(N(n,"Forward-slash in opening tag not followed by >"),n.state=f.ATTRIB);continue;case f.ATTRIB:if(I(c))continue;c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME:c==="="?n.state=f.ATTRIB_VALUE:c===">"?(N(n,"Attribute without value"),n.attribValue=n.attribName,fe(n),q(n)):I(c)?n.state=f.ATTRIB_NAME_SAW_WHITE:F(C,c)?n.attribName+=c:N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME_SAW_WHITE:if(c==="=")n.state=f.ATTRIB_VALUE;else{if(I(c))continue;N(n,"Attribute without value"),n.tag.attributes[n.attribName]="",n.attribValue="",A(n,"onattribute",{name:n.attribName,value:""}),n.attribName="",c===">"?q(n):F(w,c)?(n.attribName=c,n.state=f.ATTRIB_NAME):(N(n,"Invalid attribute name"),n.state=f.ATTRIB)}continue;case f.ATTRIB_VALUE:if(I(c))continue;R(c)?(n.q=c,n.state=f.ATTRIB_VALUE_QUOTED):(n.opt.unquotedAttributeValues||z(n,"Unquoted attribute value"),n.state=f.ATTRIB_VALUE_UNQUOTED,n.attribValue=c);continue;case f.ATTRIB_VALUE_QUOTED:if(c!==n.q){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_Q:n.attribValue+=c;continue}fe(n),n.q="",n.state=f.ATTRIB_VALUE_CLOSED;continue;case f.ATTRIB_VALUE_CLOSED:I(c)?n.state=f.ATTRIB:c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(N(n,"No whitespace between attributes"),n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_VALUE_UNQUOTED:if(!X(c)){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_U:n.attribValue+=c;continue}fe(n),c===">"?q(n):n.state=f.ATTRIB;continue;case f.CLOSE_TAG:if(n.tagName)c===">"?le(n):F(C,c)?n.tagName+=c:n.script?(n.script+="</"+n.tagName+c,n.tagName="",n.state=f.SCRIPT):(I(c)||N(n,"Invalid tagname in closing tag"),n.state=f.CLOSE_TAG_SAW_WHITE);else{if(I(c))continue;k(w,c)?n.script?(n.script+="</"+c,n.state=f.SCRIPT):N(n,"Invalid tagname in closing tag."):n.tagName=c}continue;case f.CLOSE_TAG_SAW_WHITE:if(I(c))continue;c===">"?le(n):N(n,"Invalid characters in closing tag");continue;case f.TEXT_ENTITY:case f.ATTRIB_VALUE_ENTITY_Q:case f.ATTRIB_VALUE_ENTITY_U:var P,j;switch(n.state){case f.TEXT_ENTITY:P=f.TEXT,j="textNode";break;case f.ATTRIB_VALUE_ENTITY_Q:P=f.ATTRIB_VALUE_QUOTED,j="attribValue";break;case f.ATTRIB_VALUE_ENTITY_U:P=f.ATTRIB_VALUE_UNQUOTED,j="attribValue";break}if(c===";"){var V=ye(n);n.opt.unparsedEntities&&!Object.values(r.XML_ENTITIES).includes(V)?((n.entityCount+=1)>n.opt.maxEntityCount&&z(n,"Parsed entity count exceeds max entity count"),(n.entityDepth+=1)>n.opt.maxEntityDepth&&z(n,"Parsed entity depth exceeds max entity depth"),n.entity="",n.state=P,n.write(V),n.entityDepth-=1):(n[j]+=V,n.entity="",n.state=P)}else F(n.entity.length?E:O,c)?n.entity+=c:(N(n,"Invalid character in entity name"),n[j]+="&"+n.entity+c,n.entity="",n.state=P);continue;default:throw new Error(n,"Unknown state: "+n.state)}return n.position>=n.bufferCheckPosition&&s(n),n}String.fromCodePoint||(function(){var i=String.fromCharCode,n=Math.floor,h=function(){var c=16384,S=[],L,P,j=-1,V=arguments.length;if(!V)return"";for(var se="";++j<V;){var M=Number(arguments[j]);if(!isFinite(M)||M<0||M>1114111||n(M)!==M)throw RangeError("Invalid code point: "+M);M<=65535?S.push(M):(M-=65536,L=(M>>10)+55296,P=M%1024+56320,S.push(L,P)),(j+1===V||S.length>c)&&(se+=i.apply(null,S),S.length=0)}return se};Object.defineProperty?Object.defineProperty(String,"fromCodePoint",{value:h,configurable:!0,writable:!0}):String.fromCodePoint=h})()})(typeof _e=="undefined"?_e.sax={}:_e)});var tr={};jt(tr,{initKf8File:()=>Rt,initMobiFile:()=>Pt});var $=Uint8Array,ue=Uint16Array,Xt=Int32Array,Qe=new $([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Ke=new $([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Yt=new $([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Je=function(r,e){for(var t=new ue(31),s=0;s<31;++s)t[s]=e+=1<<r[s-1];for(var o=new Xt(t[30]),s=1;s<30;++ Line truncated
`},headless:!1,chunkSize:1e4,emptyTag:"",cdata:!1}};function pn(r){return r.toLowerCase()}function mn(r){return typeof r=="object"&&r!==null&&Object.keys(r).length===0}function Fe(r,e,t){for(let s of r)e=s(e,t);return e}function de(r,e,t){let s=Object.create(null);s.value=t,s.writable=!0,s.enumerable=!0,s.configurable=!0,Object.defineProperty(r,e,s)}var Ve=class extends wt.EventEmitter{constructor(e){super(),Z(this,"options"),Z(this,"remaining",""),Z(this,"saxParser"),Z(this,"resultObject",null),Z(this,"EXPLICIT_CHARKEY"),Z(this,"errThrown",!1),Z(this,"ended",!1),Z(this,"processAsync",()=>{try{if(this.remaining.length<=this.options.chunkSize){let t=this.remaining;this.remaining="",this.saxParser=this.saxParser.write(t),this.saxParser.close()}else{let t=this.remaining.slice(0,this.options.chunkSize);this.remaining=this.remaining.slice(this.options.chunkSize),this.saxParser=this.saxParser.write(t),setTimeout(this.processAsync,0)}}catch(t){this.errThrown||(this.errThrown=!0,this.emit("error",t))}}),Z(this,"assignOrPush",(t,s,o)=>{s in t?(Array.isArray(t[s])||de(t,s,[t[s]]),t[s].push(o)):this.options.explicitArray?de(t,s,[o]):de(t,s,o)}),Z(this,"reset",()=>{this.removeAllListeners(),this.saxParser=Et.default.parser(this.options.strict,{trim:!1,normalize:!1,xmlns:this.options.xmlns}),this.errThrown=!1,this.saxParser.onerror=a=>{this.saxParser.resume(),this.errThrown||(this.errThrown=!0,this.emit("error",a))},this.saxParser.onend=()=>{this.ended||(this.ended=!0,this.emit("end",this.resultObject))},this.ended=!1,this.EXPLICIT_CHARKEY=this.options.explicitCharkey,this.resultObject=null;let t=[],s=this.options.attrkey,o=this.options.charkey;this.saxParser.onopentag=a=>{let u={};u[o]="",this.options.ignoreAttrs||Object.keys(a.attributes).forEach(d=>{!(s in u)&&!this.options.mergeAttrs&&(u[s]={});let m=this.options.attrValueProcessors?Fe(this.options.attrValueProcessors,a.attributes[d],d):a.attributes[d],v=this.options.attrNameProcessors?Fe(this.options.attrNameProcessors,d):d;this.options.mergeAttrs?this.assignOrPush(u,v,m):de(u[s],v,m)}),u["#name"]=this.options.tagNameProcessors?Fe(this.options.tagNameProcessors,a.name):a.name,this.options.xmlns&&(u[this.options.xmlnskey]={uri:a.uri,local:a.local}),t.push(u)},this.saxParser.onclosetag=()=>{let a=t.pop(),u=a["#name"];(!this.options.explicitChildren||!this.options.preserveChildrenOrder)&&delete a["#name"];let d;a.cdata===!0&&(d=a.cdata,delete a.cdata);let m=t[t.length-1],v="";if(a[o].match(/^\s*$/)&&!d?(v=a[o],delete a[o]):(this.options.trim&&(a[o]=a[o].trim()),this.options.normalize&&(a[o]=a[o].replace(/\s{2,}/g," ").trim()),a[o]=this.options.valueProcessors?Fe(this.options.valueProcessors,a[o],u):a[o],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])),mn(a)&&(typeof this.options.emptyTag=="function"?a=this.options.emptyTag():a=this.options.emptyTag!==""?this.options.emptyTag:v),this.options.validator){let g=`/${t.map(p=>p["#name"]).concat(u).join("/")}`;(()=>{try{a=this.options.validator(g,m&&m[u],a)}catch(p){this.emit("error",p)}})()}if(this.options.explicitChildren&&!this.options.mergeAttrs&&typeof a=="object")if(this.options.preserveChildrenOrder){if(m){m[this.options.childkey]=m[this.options.childkey]||[];let g={};Object.keys(a).forEach(p=>{de(g,p,a[p])}),m[this.options.childkey].push(g),delete a["#name"],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])}}else{let g={};this.options.attrkey in a&&(g[this.options.attrkey]=a[this.options.attrkey],delete a[this.options.attrkey]),!this.options.charsAsChildren&&this.options.charkey in a&&(g[this.options.charkey]=a[this.options.charkey],delete a[this.options.charkey]),Object.getOwnPropertyNames(a).length>0&&(g[this.options.childkey]=a),a=g}if(t.length>0)this.assignOrPush(m,u,a);else{if(this.options.explicitRoot){let g=a,p={};de(p,u,g),a=p}this.resultObject=a,this.ended=!0,this.emit("end",this.resultObject)}};let l=a=>{let u=t[t.length-1];if(u&&(u[o]+=a,this.options.explicitChildren&&this.options.preserveChildrenOrder&&this.options.charsAsChildren&&(this.options.includeWhiteChars||a.replace(/\\n/g,"").trim()!==""))){u[this.options.childkey]=u[this.options.childkey]||[];let d={"#name":"__text__"};d[o]=a,this.options.normalize&&(d[o]=d[o].replace(/\s{2,}/g," ").trim()),u[this.options.childkey].push(d)}return u};this.saxParser.ontext=l,this.saxParser.oncdata=a=>{let u=l(a);u&&(u.cdata=!0)}}),Z(this,"parseString",(t,s)=>{s&&typeof s=="function"&&(this.on("end",o=>{this.reset(),s(null,o)}),this.on("error",o=>{this.reset(),s(o)}));try{return t=t.toString(),t.trim()===""?(this.emit("end",null),!0):(t=vn(t),this.options.async?(this.remaining=t,setTimeout(this.processAsync,0),this.saxParser):this.saxParser.write(t).close())}catch(o){if(!this.errThrown&&!this.ended)this.emit("error",o),this.errThrown=!0;else if(this.ended)throw o}}),Z(this,"parseStringPromise",t=>new Promise((s,o)=>{this.parseString(t,(l,a)=>{l?o(l):s(a)})})),this.options={},Object.keys(bt["0.2"]).forEach(t=>{this.options[t]=b Line truncated
/*! Bundled license information:
sax/lib/sax.js:
(*! http://mths.be/fromcodepoint v0.1.0 by @mathias *)
*/
+6 -2
View File
@@ -15,7 +15,11 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
r = await up('d.docx', docx); assert.equal(r.status, 200); r = await up('d.docx', docx); assert.equal(r.status, 200);
assert.equal((await up('fake.pdf', Buffer.from('<html>not a pdf</html>'))).status, 400, 'a file must look like what its name says'); assert.equal((await up('fake.pdf', Buffer.from('<html>not a pdf</html>'))).status, 400, 'a file must look like what its name says');
assert.equal((await up('fake.epub', pdf)).status, 400); assert.equal((await up('x.exe', epub)).status, 400); assert.equal((await up('plain.docx', epub)).status, 400, 'an EPUB is not a DOCX'); assert.equal((await up('fake.epub', pdf)).status, 400); assert.equal((await up('x.exe', epub)).status, 400); assert.equal((await up('plain.docx', epub)).status, 400, 'an EPUB is not a DOCX');
assert.equal((await call('/list')).status, 200); assert.equal((await (await call('/list')).json()).length, 3); const mobi = fs.readFileSync(path.join(fx, 'test.mobi')), drm = fs.readFileSync(path.join(fx, 'drm.mobi'));
r = await up('k.azw3', mobi); assert.equal(r.status, 200); assert.equal((await r.json()).ext, 'azw3', 'DRM-free Kindle files are accepted');
r = await up('locked.mobi', drm); assert.equal(r.status, 400); assert.match((await r.json()).error, /DRM/, 'books with DRM are refused with a clear reason');
assert.equal((await up('fake.mobi', pdf)).status, 400, 'a MOBI must look like one');
assert.equal((await call('/list')).status, 200); assert.equal((await (await call('/list')).json()).length, 4);
assert.equal((await (await call('/list', { headers: { 'x-user': 'b' } })).json()).length, 0, 'each person has their own shelf'); assert.equal((await (await call('/list', { headers: { 'x-user': 'b' } })).json()).length, 0, 'each person has their own shelf');
assert.equal((await call('/book/' + e.id + '/file', { headers: { 'x-user': 'b' } })).status, 404, "nobody can open another person's book"); assert.equal((await call('/book/' + e.id + '/file', { headers: { 'x-user': 'b' } })).status, 404, "nobody can open another person's book");
r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=0-4' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '%PDF-'); assert.equal(r.headers.get('content-range'), `bytes 0-4/${pdf.length}`); r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=0-4' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '%PDF-'); assert.equal(r.headers.get('content-range'), `bytes 0-4/${pdf.length}`);
@@ -34,7 +38,7 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
assert.deepEqual(ns.map(n => n.id).sort(), ['n1', 'n2'], 'two devices add notes without losing each other'); assert.equal(ns.find(n => n.id === 'n1').text, 'hello', 'an older edit does not win'); assert.deepEqual(ns.map(n => n.id).sort(), ['n1', 'n2'], 'two devices add notes without losing each other'); assert.equal(ns.find(n => n.id === 'n1').text, 'hello', 'an older edit does not win');
ns = await note([{ id: 'n1', t: 20, del: true, loc: 'y' }]); assert.deepEqual(ns.map(n => n.id), ['n2'], 'deleted notes stay deleted'); ns = await note([{ id: 'n1', t: 20, del: true, loc: 'y' }]); assert.deepEqual(ns.map(n => n.id), ['n2'], 'deleted notes stay deleted');
assert.equal((await (await call('/book/' + e.id + '/notes')).json()).length, 1); assert.equal((await (await call('/book/' + e.id + '/notes')).json()).length, 1);
assert.equal((await call('/book/' + e.id, { method: 'DELETE' })).status, 200); assert.equal((await (await call('/list')).json()).length, 2); assert.equal((await call('/book/' + e.id, { method: 'DELETE' })).status, 200); assert.equal((await (await call('/list')).json()).length, 3);
assert.equal((await call('/book/' + e.id + '/file')).status, 404); assert.equal((await call('/book/' + e.id + '/file')).status, 404);
assert.equal((await call('/nope')).status, 404); assert.equal((await call('/nope')).status, 404);
console.log('books test ok'); console.log('books test ok');
+30
View File
@@ -22,3 +22,33 @@ data = '%PDF-1.4\n'; offs = []
for i, o in enumerate(objs): offs.append(len(data)); data += f'{i+1} 0 obj\n{o}\nendobj\n' for i, o in enumerate(objs): offs.append(len(data)); data += f'{i+1} 0 obj\n{o}\nendobj\n'
x = len(data); data += f'xref\n0 {len(objs)+1}\n0000000000 65535 f \n' + ''.join(f'{o:010d} 00000 n \n' for o in offs) + f'trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n' x = len(data); data += f'xref\n0 {len(objs)+1}\n0000000000 65535 f \n' + ''.join(f'{o:010d} 00000 n \n' for o in offs) + f'trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n'
open(os.path.join(out, 'test.pdf'), 'w').write(data) open(os.path.join(out, 'test.pdf'), 'w').write(data)
# minimal MOBI 6 (uncompressed text, no images), enough to open in a reader. Set DRM=1 in the environment to mark it encrypted.
import struct
def mkmobi(path, drm=False):
html = '<html><head><title>Mobi Rabbits</title></head><body><h1>Chapter One</h1>' + ''.join('<p>The rabbit hops through the clover field number %d, nibbling as it goes.</p>' % i for i in range(30)) + '<mbp:pagebreak/><h1>Chapter Two</h1>' + ''.join('<p>Carrots are the second chapter topic, paragraph %d.</p>' % i for i in range(30)) + '</body></html>'
text = html.encode('utf8'); recs = [text[i:i+4096] for i in range(0, len(text), 4096)]
title = b'Mobi Rabbits'; hdrlen = 232
exth = b''
def item(t, v): return struct.pack('>II', t, 8 + len(v)) + v
body = item(100, b'M. Author') + item(503, title); exth = b'EXTH' + struct.pack('>II', 12 + len(body), 2) + body
exth += b'\0' * (-len(exth) % 4)
r0 = struct.pack('>HHIHHHH', 1, 0, len(text), len(recs), 4096, 2 if drm else 0, 0)
mobi = bytearray(hdrlen)
mobi[0:4] = b'MOBI'; struct.pack_into('>I', mobi, 4, hdrlen); struct.pack_into('>I', mobi, 8, 2); struct.pack_into('>I', mobi, 12, 65001); struct.pack_into('>I', mobi, 16, 1234); struct.pack_into('>I', mobi, 20, 6)
for off in range(24, 64, 4): struct.pack_into('>I', mobi, off, 0xFFFFFFFF)
name_off = 16 + hdrlen + len(exth)
struct.pack_into('>II', mobi, 68 - 16, name_off, len(title)); struct.pack_into('>I', mobi, 72 - 16 + 4, 9)
struct.pack_into('>I', mobi, 0x5C - 16 + 0, 0)
struct.pack_into('>I', mobi, 0x6C - 16, 0xFFFFFFFF); struct.pack_into('>I', mobi, 0x70 - 16, 0xFFFFFFFF)
struct.pack_into('>I', mobi, 0x80 - 16, 0x40)
for off in (0x90 - 16, 0x94 - 16, 0x98 - 16, 0xA0 - 16): struct.pack_into('>I', mobi, off, 0xFFFFFFFF)
struct.pack_into('>I', mobi, 0xE0 - 16, 0xFFFFFFFF); struct.pack_into('>H', mobi, 0xF2 - 16, 0)
rec0 = r0 + bytes(mobi) + exth + title + b'\0\0'
allrecs = [rec0] + recs; n = len(allrecs); off = 78 + 8 * n + 2; offs = []
for r in allrecs: offs.append(off); off += len(r)
hdr = b'MobiRabbits'.ljust(32, b'\0') + struct.pack('>HHIIIIII', 0, 0, 0, 0, 0, 0, 0, 0) + b'BOOKMOBI' + struct.pack('>IIH', 1234, 0, n)
hdr = hdr[:78 - 0] if len(hdr) >= 78 else hdr.ljust(78, b'\0')
lst = b''.join(struct.pack('>IBBBB', o, 0, 0, 0, i & 255) for i, o in enumerate(offs))
open(path, 'wb').write(hdr + lst + b'\0\0' + b''.join(allrecs))
mkmobi(os.path.join(out, 'test.mobi')); mkmobi(os.path.join(out, 'drm.mobi'), True)