Archived
164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
import type { ReadingSettings } from "../types/reader";
|
||
|
||
type EpubSpine = {
|
||
get: (target: string | number) => { href: string } | null;
|
||
spineItems?: Array<{ href: string }>;
|
||
};
|
||
|
||
type EpubBookForNav = {
|
||
ready: Promise<void>;
|
||
spine: EpubSpine;
|
||
};
|
||
|
||
type EpubRendition = {
|
||
themes: {
|
||
register: (name: string, styles: { body: Record<string, string> }) => void;
|
||
select: (name: string) => void;
|
||
fontSize: (size: string) => void;
|
||
override: (property: string, value: string, important?: boolean) => void;
|
||
};
|
||
book: EpubBookForNav & {
|
||
locations: {
|
||
generate: (size: number) => Promise<string[]>;
|
||
percentageFromCfi: (cfi: string) => number | null;
|
||
};
|
||
navigation?: {
|
||
get: (href: string) => { label?: string } | null;
|
||
};
|
||
};
|
||
currentLocation: () => Promise<{ start?: { href?: string } } | null>;
|
||
display: (target: string) => Promise<unknown>;
|
||
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<ReadingSettings["font_family"], string> = {
|
||
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<string> {
|
||
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 };
|