import type { ReadingSettings } from "../types/reader"; type EpubSpine = { get: (target: string | number) => { href: string } | null; spineItems?: Array<{ href: string }>; }; type EpubBookForNav = { ready: Promise; spine: EpubSpine; }; type EpubRendition = { themes: { register: (name: string, styles: { body: Record }) => void; select: (name: string) => void; fontSize: (size: string) => void; override: (property: string, value: string, important?: boolean) => void; }; book: EpubBookForNav & { locations: { generate: (size: number) => Promise; percentageFromCfi: (cfi: string) => number | null; }; navigation?: { get: (href: string) => { label?: string } | null; }; }; currentLocation: () => Promise<{ start?: { href?: string } } | null>; display: (target: string) => Promise; next: () => void; prev: () => void; }; type EpubManager = { settings: { gap?: number }; isRendered?: () => boolean; updateLayout?: () => void; }; type EpubRenditionWithManager = EpubRendition & { settings?: { gap?: number }; manager?: EpubManager; }; const FONT_STACKS: Record = { serif: 'Georgia, "Times New Roman", serif', "sans-serif": 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', monospace: 'ui-monospace, "Cascadia Code", monospace', }; const READER_THEME_KEY = "cloud-reader"; export function isEpubCfi(location: string | number): boolean { return typeof location === "string" && location.startsWith("epubcfi("); } /** Book progress 0–100 from an EPUB CFI (requires locations.generate). */ export function percentageFromCfi(rendition: EpubRendition, cfi: string): number | null { if (!rendition.book?.locations || !isEpubCfi(cfi)) return null; try { const pct = rendition.book.locations.percentageFromCfi(cfi); if (pct === null || pct === undefined) return null; return Math.min(100, Math.max(0, Math.round(pct * 100))); } catch { return null; } } /** Map nav/TOC href to a spine href epub.js can display. */ export function resolveSpineHref(book: EpubBookForNav, href: string): string { const hashIndex = href.indexOf("#"); const fragment = hashIndex >= 0 ? href.slice(hashIndex) : ""; const path = hashIndex >= 0 ? href.slice(0, hashIndex) : href; const candidates = [href, path]; try { candidates.push(decodeURIComponent(path), encodeURI(path)); } catch { /* ignore malformed URI encoding in nav hrefs */ } for (const candidate of candidates) { if (book.spine.get(candidate)) { return candidate.includes("#") ? candidate : candidate + fragment; } } const normalizedPath = path.replace(/^\/+/, ""); const pathFile = normalizedPath.split("/").pop() ?? normalizedPath; for (const item of book.spine.spineItems ?? []) { const spineHref = item.href.replace(/^\/+/, ""); const spineFile = spineHref.split("/").pop() ?? spineHref; if ( spineHref === normalizedPath || spineHref.endsWith(normalizedPath) || normalizedPath.endsWith(spineHref) || spineFile === pathFile ) { return item.href + fragment; } } return href; } /** epub.js paginated side padding is gap/2; map per-side margin to total gap. */ export function marginWidthToGap(marginWidth: number): number { return marginWidth * 2; } function applyEpubGap(rendition: EpubRendition, marginWidth: number): void { const gap = marginWidthToGap(marginWidth); const r = rendition as EpubRenditionWithManager; if (r.settings) { r.settings.gap = gap; } if (r.manager?.settings) { r.manager.settings.gap = gap; } if (r.manager?.isRendered?.() && r.manager.updateLayout) { r.manager.updateLayout(); } } /** Apply reading preferences inside the epub.js iframe. */ export function applyRenditionSettings( rendition: EpubRendition, settings: ReadingSettings, ): void { const fontStack = FONT_STACKS[settings.font_family] ?? FONT_STACKS.serif; applyEpubGap(rendition, settings.margin_width); rendition.themes.register(READER_THEME_KEY, { body: { color: `${settings.text_color} !important`, background: `${settings.background_color} !important`, "font-family": `${fontStack} !important`, "line-height": `${settings.line_height} !important`, }, }); rendition.themes.select(READER_THEME_KEY); rendition.themes.fontSize(`${settings.font_size}px`); } export async function updateChapterTitleFromRendition( rendition: EpubRendition, ): Promise { try { const loc = await rendition.currentLocation(); const href = loc?.start?.href; if (href && rendition.book.navigation) { const nav = rendition.book.navigation.get(href); if (nav?.label) return nav.label; } } catch { /* ignore navigation lookup failures */ } return ""; } export type { EpubRendition };