books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 2ca2d0d5a4
commit 364c7ee132
65 changed files with 25709 additions and 24 deletions

View File

@@ -0,0 +1,228 @@
<script lang="ts">
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
import { onMount } from 'svelte';
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
import {
fetchWithProgress,
themeColors,
type EngineProps,
type ReaderSettings,
type ReaderTheme,
type TocItem
} from './types.js';
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
type Annotation = { value: string; color?: string };
type Renderer = HTMLElement & {
setStyles(css: string): void;
getContents(): { doc: Document; index: number }[];
};
interface FoliateView extends HTMLElement {
book: { toc?: FoliateToc[] };
renderer: Renderer;
open(file: File): Promise<void>;
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
close(): void;
goTo(target: string | number): Promise<unknown>;
goToFraction(fraction: number): Promise<void>;
goLeft(): Promise<void>;
goRight(): Promise<void>;
addAnnotation(annotation: Annotation): Promise<unknown>;
deleteAnnotation(annotation: Annotation): Promise<unknown>;
getCFI(index: number, range: Range): string;
}
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
let {
url,
name,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onkey,
onloading,
onerror
}: EngineProps = $props();
let host = $state<FoliateView>();
let view = $state<FoliateView>();
let highlightDraw: DrawFn | undefined;
const fonts = {
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
};
function css(s: ReaderSettings, t: ReaderTheme) {
const c = themeColors[t];
const family = s.font === 'publisher' ? null : fonts[s.font];
return `
@namespace epub "http://www.idpf.org/2007/ops";
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; }
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
a:any-link { color: ${c.link} !important; }
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
img, svg, video { max-width: 100%; height: auto; }
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
::selection { background: rgba(250, 204, 21, 0.45); }`;
}
function apply(v: FoliateView) {
const r = v.renderer;
r.setAttribute('flow', settings.flow);
r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%');
r.setAttribute('max-inline-size', '720px');
r.setAttribute('max-column-count', '2');
r.setStyles(css(settings, theme));
v.style.background = themeColors[theme].bg;
}
$effect(() => {
if (view) apply(view);
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
const toToc = (items: FoliateToc[] = []): TocItem[] =>
items
.filter((i) => i.href)
.map((i) => ({
label: (i.label ?? '').trim() || 'Untitled',
target: i.href!,
children: toToc(i.subitems ?? [])
}));
export async function goTo(target: string | number) {
await view?.goTo(target);
}
export async function goToPercent(percent: number) {
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
}
export function next() {
void view?.goRight();
}
export function prev() {
void view?.goLeft();
}
export function addHighlight(h: Highlight) {
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
}
export function removeHighlight(h: Highlight) {
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
}
export function clearSelection() {
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
}
onMount(() => {
const abort = new AbortController();
let disposed = false;
let el: FoliateView | undefined;
(async () => {
try {
const base = '/vendor/foliate-js';
const [, overlayer] = await Promise.all([
import(/* @vite-ignore */ `${base}/view.js`),
import(/* @vite-ignore */ `${base}/overlayer.js`)
]);
highlightDraw = overlayer.Overlayer.highlight;
onloading(0);
const blob = await fetchWithProgress(url, onloading, abort.signal);
if (disposed) return;
onloading(null);
el = host;
if (!el) return;
el.addEventListener('relocate', (e) => {
const d = (e as CustomEvent).detail;
onrelocate({
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
locator: d.cfi,
page: null,
pageCount: null,
label: d.tocItem?.label?.trim() || null
});
});
el.addEventListener('load', (e) => {
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
doc.addEventListener('keydown', onkey);
let timer: ReturnType<typeof setTimeout>;
doc.addEventListener('selectionchange', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const sel = doc.getSelection();
const text = sel?.toString().trim();
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
try {
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
} catch {
onselection(null);
}
}, 250);
});
});
el.addEventListener('create-overlay', () => {
for (const h of highlights) addHighlight(h);
});
el.addEventListener('draw-annotation', (e) => {
const { draw, annotation } = (e as CustomEvent).detail as {
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
annotation: Annotation;
};
if (highlightDraw)
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
});
el.addEventListener('show-annotation', (e) => {
const value = (e as CustomEvent).detail.value as string;
const h = highlights.find((x) => x.locator === value);
if (h) onhighlightclick(h.id);
});
await el.open(new File([blob], name, { type: blob.type }));
if (disposed) return;
apply(el);
view = el;
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
await el.init({ lastLocation, showTextStart: !lastLocation });
onready(toToc(el.book.toc));
} catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
onerror(e instanceof Error ? e.message : 'Could not open this book');
}
})();
return () => {
disposed = true;
abort.abort();
el?.close?.();
};
});
</script>
<div class="host">
<foliate-view bind:this={host}></foliate-view>
</div>
<style>
.host {
position: absolute;
inset: 0;
}
.host :global(foliate-view) {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,538 @@
<script lang="ts">
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
// for selection, and highlights stored as page-relative rectangles.
import { onMount } from 'svelte';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import {
highlightCss,
parsePdfLocator,
pdfLocator,
type Highlight,
type HighlightColor,
type Rect
} from '#lib/books/shared.js';
import { type EngineProps, type TocItem } from './types.js';
let {
url,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onloading,
onerror
}: EngineProps = $props();
type PageInfo = { num: number; width: number; height: number }; // at scale 1
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
let scroller: HTMLDivElement;
let pdf: PDFDocumentProxy | undefined;
let pdfjs: typeof import('pdfjs-dist') | undefined;
let pages = $state<PageInfo[]>([]);
let width = $state(0);
let current = 1;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const live = new Map<number, Live>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const pageEls = new Map<number, HTMLElement>();
let observer: IntersectionObserver | undefined;
const scale = $derived.by(() => {
const first = pages[0];
if (!first || !width) return 1;
const fit = Math.min(width - 24, 920) / first.width;
return Math.max(0.3, fit * settings.pdfZoom);
});
const byPage = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
for (const h of highlights) {
const loc = parsePdfLocator(h.locator);
if (!loc || !loc.rects.length) continue;
const list = map.get(loc.page) ?? [];
list.push({ h, rects: loc.rects });
map.set(loc.page, list);
}
return map;
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
async function render(num: number) {
const el = pageEls.get(num);
if (!pdf || !pdfjs || !el || live.has(num)) return;
const layer = el.querySelector<HTMLElement>('.layer')!;
const entry: Live = { el, layer };
live.set(num, entry);
const page: PDFPageProxy = await pdf.getPage(num);
if (live.get(num) !== entry) return;
const s = scale;
const viewport = page.getViewport({ scale: s });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
const text = document.createElement('div');
text.className = 'textLayer';
layer.replaceChildren(canvas, text);
el.style.setProperty('--scale-factor', String(s));
el.style.setProperty('--total-scale-factor', String(s));
entry.task = page.render({
canvas,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
});
try {
await entry.task.promise;
if (live.get(num) !== entry) return;
await new pdfjs.TextLayer({
textContentSource: page.streamTextContent(),
container: text,
viewport
}).render();
} catch {
// cancelled (scrolled away or re-zoomed)
}
}
function unrender(num: number) {
const entry = live.get(num);
if (!entry) return;
entry.task?.cancel();
entry.layer.replaceChildren();
live.delete(num);
}
function page(node: HTMLElement, num: number) {
pageEls.set(num, node);
observer?.observe(node);
return {
destroy() {
observer?.unobserve(node);
pageEls.delete(num);
unrender(num);
}
};
}
// Re-render everything when the zoom or the width changes, keeping the reading position.
let lastScale = 0;
$effect(() => {
const s = scale;
if (!lastScale) {
lastScale = s;
return;
}
if (Math.abs(s - lastScale) < 0.001) return;
lastScale = s;
const keep = position();
for (const num of [...live.keys()]) unrender(num);
requestAnimationFrame(() => {
scrollToPosition(keep.page, keep.fraction);
for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect();
const v = scroller.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
}
});
});
function position() {
if (!scroller) return { page: 1, fraction: 0 };
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
for (const p of pages) {
const node = pageEls.get(p.num);
if (!node) continue;
const top = node.offsetTop;
const bottom = top + node.offsetHeight;
if (anchor < bottom + 8)
return {
page: p.num,
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
};
}
return { page: pages.length, fraction: 1 };
}
function scrollToPosition(num: number, fraction = 0) {
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
if (!node) return;
scroller.scrollTop =
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
}
let frame = 0;
function onscroll() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const { page: num, fraction } = position();
current = num;
onrelocate({
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
locator: pdfLocator(num),
page: num,
pageCount: pages.length,
label: labelFor(num)
});
});
}
let flatToc: { page: number; label: string }[] = [];
const labelFor = (num: number) => {
let label: string | null = null;
for (const item of flatToc) if (item.page <= num) label = item.label;
return label;
};
export async function goTo(target: string | number) {
if (typeof target === 'number') return scrollToPosition(target);
const loc = parsePdfLocator(target);
if (!loc) return;
const y = loc.rects[0]?.y;
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
else scrollToPosition(loc.page);
}
export async function goToPercent(percent: number) {
const p = (percent / 100) * pages.length;
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
}
export function next() {
scrollToPosition(current + 1);
}
export function prev() {
const { fraction } = position();
scrollToPosition(fraction > 0.1 ? current : current - 1);
}
export function addHighlight() {
// Highlights are derived from props; nothing to draw imperatively.
}
export function removeHighlight() {}
export function clearSelection() {
document.getSelection()?.removeAllRanges();
}
/** Selection rects as fractions of the page, merged per line. */
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
const box = pageEl.getBoundingClientRect();
const rects = [...range.getClientRects()]
.filter((r) => r.width > 1 && r.height > 1)
.map((r) => ({
x: (r.left - box.left) / box.width,
y: (r.top - box.top) / box.height,
w: r.width / box.width,
h: r.height / box.height
}))
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
.sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of rects) {
const last = merged.at(-1);
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
const right = Math.max(last.x + last.w, r.x + r.w);
const bottom = Math.max(last.y + last.h, r.y + r.h);
last.x = Math.min(last.x, r.x);
last.y = Math.min(last.y, r.y);
last.w = right - last.x;
last.h = bottom - last.y;
} else merged.push({ ...r });
}
return merged.slice(0, 60);
}
function hitHighlight(e: MouseEvent, num: number) {
if (!document.getSelection()?.isCollapsed) return;
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = (e.clientX - box.left) / box.width;
const y = (e.clientY - box.top) / box.height;
const hit = byPage
.get(num)
?.find(({ rects }) =>
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
);
if (hit) onhighlightclick(hit.h.id);
}
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
const outline = await doc.getOutline().catch(() => null);
if (!outline) return [];
let budget = 600;
const walk = async (items: typeof outline): Promise<TocItem[]> => {
const out: TocItem[] = [];
for (const item of items) {
if (budget-- <= 0) break;
let num: number | null = null;
try {
const dest =
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
const ref = dest?.[0];
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
else if (typeof ref === 'number') num = ref + 1;
} catch {
num = null;
}
const children = item.items?.length ? await walk(item.items) : [];
if (num) {
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
flatToc.push({ page: num, label: item.title.trim() });
} else out.push(...children);
}
return out;
};
const toc = await walk(outline);
flatToc = flatToc.sort((a, b) => a.page - b.page);
return toc;
}
onMount(() => {
let disposed = false;
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
resize.observe(scroller);
width = scroller.clientWidth;
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const num = Number((entry.target as HTMLElement).dataset.page);
if (entry.isIntersecting) void render(num);
else unrender(num);
}
},
{ root: scroller, rootMargin: '150% 0px' }
);
let selTimer: ReturnType<typeof setTimeout>;
const onSelectionChange = () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString().trim();
const node = sel?.anchorNode;
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
'.pdf-page'
);
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
return onselection(null);
const num = Number(pageEl.dataset.page);
const rects = selectionRects(sel.getRangeAt(0), pageEl);
if (!rects.length) return onselection(null);
onselection({ text, locator: pdfLocator(num, rects), page: num });
}, 250);
};
document.addEventListener('selectionchange', onSelectionChange);
(async () => {
try {
pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
onloading(0);
const task = pdfjs.getDocument({
url,
withCredentials: true,
rangeChunkSize: 1 << 20
});
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
onloading(total ? Math.min(1, loaded / total) : null);
const doc = await task.promise;
if (disposed) return void doc.destroy();
pdf = doc;
onloading(null);
// Use the first page's size for every page up front; real sizes fill in quickly.
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
pages = Array.from({ length: doc.numPages }, (_, i) => ({
num: i + 1,
width: first.width,
height: first.height
}));
void (async () => {
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
if (vp.width !== first.width || vp.height !== first.height)
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
}
})();
requestAnimationFrame(async () => {
const loc = parsePdfLocator(start);
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
else if (startPercent > 0) await goToPercent(startPercent);
onscroll();
onready(await resolveToc(doc));
});
} catch (e) {
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
}
})();
return () => {
disposed = true;
document.removeEventListener('selectionchange', onSelectionChange);
resize.disconnect();
observer?.disconnect();
for (const num of [...live.keys()]) unrender(num);
void pdf?.destroy();
};
});
</script>
<div
class={['scroller', `t-${theme}`]}
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
bind:this={scroller}
{onscroll}
>
<div class="pages">
{#each pages as p (p.num)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="pdf-page"
data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`}
use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)}
>
<div class="layer"></div>
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
{#each rects as r, i (i)}
<span
class="hl"
style:left={`${r.x * 100}%`}
style:top={`${r.y * 100}%`}
style:width={`${r.w * 100}%`}
style:height={`${r.h * 100}%`}
style:background={color(h)}
></span>
{/each}
{/each}
<span class="num">{p.num}</span>
</div>
{/each}
</div>
</div>
<style>
.scroller {
position: absolute;
inset: 0;
overflow: auto;
overscroll-behavior: contain;
}
.pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 12px 12px 96px;
min-width: fit-content;
}
.pdf-page {
position: relative;
background: white;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.12),
0 8px 24px rgb(0 0 0 / 0.08);
flex: none;
}
.t-sepia .pdf-page :global(canvas) {
filter: sepia(0.35) brightness(0.97);
}
.t-dark .pdf-page {
background: #1a1a1c;
}
.t-dark .pdf-page :global(canvas) {
filter: invert(0.9) hue-rotate(180deg);
}
.layer,
.layer :global(canvas) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hl {
position: absolute;
mix-blend-mode: multiply;
opacity: 0.45;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.t-dark .hl {
mix-blend-mode: screen;
opacity: 0.35;
}
.num {
position: absolute;
right: -2.25rem;
bottom: 0;
font-size: 0.7rem;
color: rgb(128 128 128 / 0.8);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1100px) {
.num {
display: none;
}
}
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
.pdf-page :global(.textLayer) {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 2;
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
--scale-round-x: 1px;
--scale-round-y: 1px;
}
.pdf-page :global(.textLayer :is(span, br)) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
user-select: text;
}
.pdf-page :global(.textLayer > :not(.markedContent)),
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.pdf-page :global(.textLayer .markedContent) {
display: contents;
}
.pdf-page :global(.textLayer ::selection) {
background: rgb(250 204 21 / 0.4);
color: transparent;
}
.pdf-page :global(.textLayer br::selection) {
background: transparent;
}
.pdf-page :global(.textLayer .endOfContent) {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: 0;
cursor: default;
user-select: none;
}
</style>

View File

@@ -0,0 +1,89 @@
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
import type { Highlight } from '#lib/books/shared.js';
export type TocItem = { label: string; target: string | number; children: TocItem[] };
export type Relocation = {
percent: number; // 0-100
locator: string; // EPUB CFI or `page:N`
page: number | null; // PDF page (1-based), or null for reflowable books
pageCount: number | null;
label: string | null; // current chapter
};
export type TextSelection = { text: string; locator: string; page: number | null };
export type ReaderTheme = 'light' | 'sepia' | 'dark';
export type ReaderSettings = {
theme: 'auto' | ReaderTheme;
fontSize: number; // percent
lineHeight: number;
font: 'publisher' | 'serif' | 'sans';
flow: 'paginated' | 'scrolled';
pdfZoom: number; // 1 = fit width
};
export const defaultSettings: ReaderSettings = {
theme: 'auto',
fontSize: 100,
lineHeight: 1.5,
font: 'publisher',
flow: 'paginated',
pdfZoom: 1
};
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
};
/** What both engines expose to the shell through bind:this. */
export interface ReaderEngine {
goTo(target: string | number): Promise<void>;
goToPercent(percent: number): Promise<void>;
next(): void;
prev(): void;
addHighlight(h: Highlight): void;
removeHighlight(h: Highlight): void;
clearSelection(): void;
}
export type EngineProps = {
url: string;
name: string;
start: string | null;
startPercent: number;
highlights: Highlight[];
settings: ReaderSettings;
theme: ReaderTheme;
onready: (toc: TocItem[]) => void;
onrelocate: (r: Relocation) => void;
onselection: (s: TextSelection | null) => void;
onhighlightclick: (id: number) => void;
onkey: (e: KeyboardEvent) => void;
onloading: (fraction: number | null) => void;
onerror: (message: string) => void;
};
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
export async function fetchWithProgress(
url: string,
onprogress: (fraction: number | null) => void,
signal?: AbortSignal
): Promise<Blob> {
const res = await fetch(url, { signal, credentials: 'same-origin' });
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
const total = Number(res.headers.get('content-length')) || 0;
if (!res.body || !total) return res.blob();
const reader = res.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onprogress(Math.min(1, loaded / total));
}
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
}