diora-web/static/js/covers.js
mrwnslz c2a9873b0d 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

227 lines
9.1 KiB
JavaScript

/*
* Book covers, end-to-end encrypted (docs/library.md). A cover the server has
* (has_cover) is fetched and decrypted here; a book without one gets it from its own
* file — on a device that keeps the books (the desktop and Android apps, or the
* browser's cache): the EPUB's cover image or a PDF's first page, scaled to 300 px,
* encrypted with the user's key and sent once. The server never sees a cover.
*
* Gently: one book at a time, when the page is idle, never while a book is open, and
* not at all on e-ink or in diora Reader (the list there has no covers). Decrypted
* covers are kept on this device (IndexedDB) and handed out as object URLs.
*
* DioraCovers.url(book) an object URL, or null (and it is looked for, once)
* DioraCovers.onReady(fn) fn(bookId) when one becomes available
*
* Chrome-61-safe: no ?. / ??.
*/
(function () {
'use strict';
const MAX_W = 300, MAX_H = 450, QUALITY = 0.72;
const html = document.documentElement;
const off = () => html.classList.contains('nui-reader-app') || (document.body && document.body.classList.contains('eink'));
const readerOpen = () => {
const o = document.getElementById('reader-overlay');
return !!o && o.style.display !== 'none'; // open: '' (the markup starts with none)
};
const localBooks = () => !!(window.dioraDesktop || window.DioraNative); // the app keeps every file
const urls = new Map(); // id -> object URL
const tried = new Set(); // looked for this session
const queue = [];
const listeners = [];
let busy = false;
// ---- this device's store ---------------------------------------------------------------
let dbp = null;
function db() {
if (!dbp) {
dbp = new Promise(resolve => {
try {
const req = indexedDB.open('diora_covers', 1);
req.onupgradeneeded = () => req.result.createObjectStore('covers');
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch (e) { resolve(null); }
});
}
return dbp;
}
async function readLocal(id) {
const d = await db();
if (!d) return null;
return new Promise(resolve => {
try {
const req = d.transaction('covers').objectStore('covers').get(String(id));
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
} catch (e) { resolve(null); }
});
}
async function writeLocal(id, blob) {
const d = await db();
if (!d) return;
try { d.transaction('covers', 'readwrite').objectStore('covers').put(blob, String(id)); } catch (e) {}
}
function ready(id, blob) {
if (urls.has(id)) URL.revokeObjectURL(urls.get(id));
urls.set(id, URL.createObjectURL(blob));
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
}
// ---- finding one ------------------------------------------------------------------------------
async function key() {
return typeof getOrCreateEncKey === 'function' ? getOrCreateEncKey() : null;
}
async function fromServer(id) {
const r = await fetch('/books/' + id + '/cover/', {credentials: 'same-origin'});
if (!r.ok) return null;
const d = await r.json();
const plain = await decryptBytes(await key(), d.cover_iv, d.cover_ct);
return new Blob([plain], {type: 'image/jpeg'});
}
// The book's own file, decrypted: the browser's cache, or the app's store.
async function bookBytes(id) {
let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null;
if (!rec && localBooks()) {
const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'});
if (r.ok) rec = await r.json();
}
if (!rec || !rec.data_ct) return null;
return decryptBytes(await key(), rec.data_iv, rec.data_ct);
}
async function epubCover(bytes) {
const zip = await JSZip.loadAsync(bytes);
const container = zip.file('META-INF/container.xml');
if (!container) return null;
const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml');
const root = cdoc.querySelector('rootfile');
if (!root) return null;
const opfPath = root.getAttribute('full-path');
const dir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
const opf = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
const items = Array.prototype.slice.call(opf.getElementsByTagName('item'));
const meta = Array.prototype.slice.call(opf.getElementsByTagName('meta')).filter(m => m.getAttribute('name') === 'cover')[0];
const isImage = i => /^image\//.test(i.getAttribute('media-type') || '');
let item = items.filter(i => /\bcover-image\b/.test(i.getAttribute('properties') || ''))[0] ||
(meta ? items.filter(i => i.getAttribute('id') === meta.getAttribute('content') && isImage(i))[0] : null) ||
items.filter(i => isImage(i) && /cover/i.test((i.getAttribute('id') || '') + (i.getAttribute('href') || '')))[0];
if (!item) return null;
const path = decodeURIComponent(resolveEpubPath(dir, item.getAttribute('href')));
const file = zip.file(path) || zip.file(path.replace(/^\//, ''));
if (!file) return null;
return new Blob([await file.async('arraybuffer')], {type: item.getAttribute('media-type')});
}
async function pdfCover(bytes) {
if (typeof pdfjsLib === 'undefined') return null;
const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes))
: pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise;
const canvas = document.createElement('canvas');
try {
const page = await pdf.getPage(1);
const v = page.getViewport({scale: 1});
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
const viewport = page.getViewport({scale});
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
} finally {
try { pdf.destroy(); } catch (e) {}
}
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
}
// Any image to a small JPEG.
async function shrink(blob) {
const url = URL.createObjectURL(blob);
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = reject;
i.src = url;
});
const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
return await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
} finally {
URL.revokeObjectURL(url);
}
}
async function extract(book) {
const bytes = await bookBytes(book.id);
if (!bytes) return null;
let blob = book.type === 'pdf' ? await pdfCover(bytes) : await epubCover(bytes);
if (!blob) return null;
if (book.type !== 'pdf') blob = await shrink(blob);
if (!blob) return null;
const enc = await encryptBytes(await key(), new Uint8Array(await blob.arrayBuffer()));
fetch('/books/' + book.id + '/cover/', {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({cover_ct: enc.ciphertext, cover_iv: enc.iv}),
}).then(() => { book.has_cover = true; }).catch(() => {});
return blob;
}
async function find(book) {
const local = await readLocal(book.id);
if (local) return local;
let blob = null;
if (book.has_cover) blob = await fromServer(book.id).catch(() => null);
if (!blob && !book.has_cover && book.keyOk !== false) blob = await extract(book).catch(() => null);
if (blob) writeLocal(book.id, blob);
return blob;
}
// ---- the queue: one at a time, when idle -------------------------------------------------------
const idle = fn => (window.requestIdleCallback ? requestIdleCallback(fn, {timeout: 3000}) : setTimeout(fn, 400));
function pump() {
if (busy || !queue.length) return;
if (readerOpen()) { setTimeout(pump, 5000); return; }
busy = true;
idle(async () => {
const book = queue.shift();
try {
const blob = await find(book);
if (blob) ready(book.id, blob);
} catch (e) {}
busy = false;
setTimeout(pump, 150);
});
}
function url(book) {
if (!book || off()) return null;
if (urls.has(book.id)) return urls.get(book.id);
if (!tried.has(book.id)) {
tried.add(book.id);
// Stored here already: at once, without waiting in the queue.
readLocal(book.id).then(blob => {
if (blob) ready(book.id, blob);
else { queue.push(book); pump(); }
});
}
return null;
}
// A book read just now: its file is open anyway (the reader calls this).
function forget(id) {
tried.delete(id);
}
window.DioraCovers = {url, onReady(fn) { listeners.push(fn); }, forget};
})();