This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/frontend/src/utils/epubRendition.ts
T
2026-06-04 06:42:27 -05:00

164 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 0100 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 };