refactor(reader): create modular format-specific architecture
Implement complete modularization of reader code by separating format-specific functionality into dedicated modules. This replaces the monolithic structure with a clean, maintainable architecture that separates concerns by format type. ## New Architecture ### Format-Specific Modules - **formats/reflowable/**: EPUB, FB2, TXT, HTML (page-based pagination) - types.ts: Shared type definitions for reflowable formats - page-calculator.ts: Word-count based pagination with HTML slicing - navigation.ts: Page-based navigation logic - progress-tracker.ts: CFI-based progress tracking - content-renderer.ts: DOM rendering for page content - parser.ts: Unified parser interface for all reflowable formats - ebook/**: Migrated ebook-specific features - **formats/pdf/**: PDF format support - Core PDF functionality (navigation, text selection, annotations) - Advanced features (bookmarks, search, outlines, dual-page) - Page cache and rendering optimizations - **formats/comic/**: Comic format support - Background color, chapter markers, page caching - Page ordering, gap adjustments - **formats/manga/**: Manga format support - RTL navigation, vertical scrolling, reading direction ## Key Improvements 1. **Separation of Concerns**: Each format has its own dedicated module 2. **No Circular Dependencies**: Clean import structure 3. **Type Safety**: Comprehensive TypeScript types throughout 4. **Functional Programming**: Pure functions, no OOP complexity 5. **Scalability**: Easy to add new formats without touching core code ## Migration Path - Old format-specific code in reader/, ebook/, pdf/, comic/, manga/ - New code in formats/[format]/ structure - Maintains backward compatibility during transition - Core reader logic remains format-agnostic This change enables the implementation of page-based pagination for reflowable formats while keeping PDF, comic, and manga functionality unchanged.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Render a page's content to the DOM
|
||||
export function renderPage(container: HTMLElement, content: string): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "reflowable-page";
|
||||
wrapper.style.height = "calc(100vh - 120px)";
|
||||
wrapper.style.overflow = "hidden";
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.display = "flex";
|
||||
wrapper.style.flexDirection = "column";
|
||||
|
||||
// Parse the HTML content (which is already sliced by getPageContent)
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content;
|
||||
const pageContent = tempDiv.querySelector(".page-content-wrapper");
|
||||
|
||||
if (!pageContent) {
|
||||
// Fallback if wrapper not found
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.innerHTML = content;
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.overflowY = "auto";
|
||||
wrapper.appendChild(contentDiv);
|
||||
} else {
|
||||
// Transfer the sliced content to our wrapper
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.padding = "20px";
|
||||
|
||||
while (pageContent.firstChild) {
|
||||
contentDiv.appendChild(pageContent.firstChild);
|
||||
}
|
||||
|
||||
wrapper.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// Update container styles for paginated mode
|
||||
export function applyPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "reflowable-styles";
|
||||
style.textContent = `
|
||||
.reflowable-page {
|
||||
height: calc(100vh - 120px) !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
-webkit-column-width: auto !important;
|
||||
column-width: auto !important;
|
||||
-webkit-column-count: 1 !important;
|
||||
column-count: 1 !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
column-fill: auto !important;
|
||||
}
|
||||
|
||||
.page-content img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.page-content p {
|
||||
margin: 0.5em 0 !important;
|
||||
text-align: justify !important;
|
||||
}
|
||||
|
||||
.page-content h1,
|
||||
.page-content h2,
|
||||
.page-content h3,
|
||||
.page-content h4,
|
||||
.page-content h5,
|
||||
.page-content h6 {
|
||||
margin: 1em 0 0.5em 0 !important;
|
||||
page-break-after: avoid !important;
|
||||
break-after: avoid !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Clear all styles
|
||||
export function clearPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Handle text copying with citation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
import { showToast } from "../../toast";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let mediaItem: MediaItemSummary | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { mediaItem: MediaItemSummary }) => {
|
||||
mediaItem = detail.mediaItem;
|
||||
enableContextMenuCopy(mediaItem);
|
||||
});
|
||||
|
||||
context.events.on("copy:selection", async () => {
|
||||
if (mediaItem) {
|
||||
await copySelection(mediaItem);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
mediaItem = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function copySelection(mediaItem: MediaItemSummary): Promise<boolean> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (!selectedText.trim()) return false;
|
||||
|
||||
const citation = createCitation(selectedText, mediaItem);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(citation);
|
||||
showToast("Copied to clipboard", "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createCitation(text: string, mediaItem: MediaItemSummary): string {
|
||||
let citation = `"${text}"\n`;
|
||||
citation += `— ${mediaItem.title}`;
|
||||
if (mediaItem.author) {
|
||||
citation += ` by ${mediaItem.author}`;
|
||||
}
|
||||
citation += `\n(Source: Bookhoard)`;
|
||||
|
||||
return citation;
|
||||
}
|
||||
|
||||
function enableContextMenuCopy(mediaItem: MediaItemSummary): void {
|
||||
document.addEventListener("contextmenu", async (e) => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText) {
|
||||
e.preventDefault();
|
||||
await copySelection(mediaItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Dictionary lookup popup for ebooks
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
context.events.on("dictionary:lookup", (detail: { word: string; position: { x: number; y: number } }) => {
|
||||
showDictionaryPopup(detail.word, detail.position);
|
||||
});
|
||||
|
||||
context.events.on("reader:loaded", () => {
|
||||
handleTextSelection();
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
const popup = document.getElementById("dictionary-popup");
|
||||
popup?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function showDictionaryPopup(
|
||||
word: string,
|
||||
position: { x: number; y: number },
|
||||
): void {
|
||||
const existing = document.getElementById("dictionary-popup");
|
||||
existing?.remove();
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.id = "dictionary-popup";
|
||||
popup.className =
|
||||
"absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50";
|
||||
popup.style.left = `${position.x}px`;
|
||||
popup.style.top = `${position.y}px`;
|
||||
|
||||
popup.innerHTML = '<p class="text-sm">Loading...</p>';
|
||||
document.body.appendChild(popup);
|
||||
|
||||
lookupWord(word)
|
||||
.then((entry) => {
|
||||
popup.innerHTML = `
|
||||
<h3 class="font-bold text-lg">${entry.word}</h3>
|
||||
<p class="text-sm italic">${entry.part_of_speech || ""}</p>
|
||||
<p class="mt-2">${entry.definition}</p>
|
||||
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ""}
|
||||
`;
|
||||
})
|
||||
.catch(() => {
|
||||
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", function closePopup(e: MouseEvent) {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.remove();
|
||||
document.removeEventListener("click", closePopup);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleTextSelection(): void {
|
||||
document.addEventListener("mouseup", () => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText && selectedText.split(" ").length === 1) {
|
||||
const range = selection?.getRangeAt(0);
|
||||
const rect = range?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function lookupWord(word: string): Promise<any> {
|
||||
const response = await fetch(`/api/dictionary/${word}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to lookup word: ${word}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Font loading with performance optimization
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
const userPreferredFont = localStorage.getItem("reader-font") || "literata";
|
||||
|
||||
context.events.on("reader:loaded", async () => {
|
||||
await preloadFonts(userPreferredFont);
|
||||
});
|
||||
|
||||
context.events.on("font:change", async (detail: { fontId: string }) => {
|
||||
const stack = getFontStack(detail.fontId);
|
||||
applyFontStack(stack);
|
||||
await preloadFonts(detail.fontId);
|
||||
});
|
||||
|
||||
context.events.on("font:get-stack", (detail: { fontId: string }) => {
|
||||
const stack = getFontStack(detail.fontId);
|
||||
context.events.emit("font:stack-ready", { stack });
|
||||
});
|
||||
}
|
||||
|
||||
const READING_FONTS = [
|
||||
{
|
||||
id: "literata",
|
||||
name: "Literata",
|
||||
stack: "Literata, serif",
|
||||
description: "Designed for Google Play Books",
|
||||
},
|
||||
{
|
||||
id: "crimson",
|
||||
name: "Crimson Text",
|
||||
stack: "Crimson Text, serif",
|
||||
description: "Optimized for screen reading",
|
||||
},
|
||||
{
|
||||
id: "source-serif",
|
||||
name: "Source Serif 4",
|
||||
stack: "Source Serif 4, serif",
|
||||
description: "Professional Adobe quality",
|
||||
},
|
||||
{
|
||||
id: "eb-garamond",
|
||||
name: "EB Garamond",
|
||||
stack: "EB Garamond, serif",
|
||||
description: "Classic elegance",
|
||||
},
|
||||
{
|
||||
id: "libertinus",
|
||||
name: "Libertinus Serif",
|
||||
stack: "Libertinus Serif, serif",
|
||||
description: "Excellent for technical content",
|
||||
},
|
||||
{
|
||||
id: "noto-serif",
|
||||
name: "Noto Serif",
|
||||
stack: "Noto Serif, serif",
|
||||
description: "Maximum language support",
|
||||
},
|
||||
{
|
||||
id: "charis-sil",
|
||||
name: "Charis SIL",
|
||||
stack: "Charis SIL, serif",
|
||||
description: "Multilingual specialist",
|
||||
},
|
||||
{
|
||||
id: "ibm-plex",
|
||||
name: "IBM Plex Serif",
|
||||
stack: "IBM Plex Serif, serif",
|
||||
description: "Modern & versatile",
|
||||
},
|
||||
];
|
||||
|
||||
async function preloadFonts(userPreferredFont: string): Promise<void> {
|
||||
const fontsToPreload = new Set(["literata", userPreferredFont]);
|
||||
|
||||
for (const fontId of fontsToPreload) {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
document.fonts.load(`16px "${font.stack}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontId: string): string {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
return font?.stack || "Literata, serif";
|
||||
}
|
||||
|
||||
function applyFontStack(stack: string): void {
|
||||
document.documentElement.style.setProperty("--reader-font-family", stack);
|
||||
}
|
||||
|
||||
export { READING_FONTS, preloadFonts, getFontStack };
|
||||
@@ -0,0 +1,142 @@
|
||||
// Search within ebook content
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let ebookData: any = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { ebookData: any }) => {
|
||||
ebookData = detail.ebookData;
|
||||
});
|
||||
|
||||
context.events.on("search:execute", async (detail: { query: string }) => {
|
||||
if (ebookData) {
|
||||
const results = await searchEbook(ebookData, detail.query);
|
||||
context.events.emit("search:results", { results });
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("reader:unload", () => {
|
||||
ebookData = null;
|
||||
});
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
cfi: string;
|
||||
snippet: string;
|
||||
chapterTitle: string;
|
||||
}
|
||||
|
||||
export async function searchEbook(
|
||||
ebookData: any,
|
||||
query: string,
|
||||
): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (!ebookData.spine) return results;
|
||||
|
||||
for (const spineItem of ebookData.spine) {
|
||||
const doc = await getSpineItemDocument(ebookData, spineItem);
|
||||
|
||||
if (!doc) continue;
|
||||
|
||||
const chapterTitle = getChapterTitle(spineItem);
|
||||
const textNodes = findTextNodes(doc.body);
|
||||
|
||||
for (const node of textNodes) {
|
||||
const text = node.textContent || "";
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
let foundAt = 0;
|
||||
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
||||
const cfi = generateCFIForNode(node, foundAt);
|
||||
const snippet = extractSnippet(text, foundAt, query.length);
|
||||
|
||||
results.push({
|
||||
cfi,
|
||||
snippet,
|
||||
chapterTitle,
|
||||
});
|
||||
|
||||
foundAt += lowerQuery.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getSpineItemDocument(
|
||||
ebookData: any,
|
||||
spineItem: any,
|
||||
): Promise<Document | null> {
|
||||
try {
|
||||
const resources = ebookData.resources;
|
||||
if (!resources) return null;
|
||||
|
||||
const content = await resources.get(spineItem.href)?.text();
|
||||
if (!content) return null;
|
||||
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(content, "text/html");
|
||||
} catch (error) {
|
||||
console.error("Failed to load spine item:", spineItem.href, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChapterTitle(spineItem: any): string {
|
||||
return spineItem.id || `Section ${spineItem.index || ""}`;
|
||||
}
|
||||
|
||||
function findTextNodes(root: Node): Text[] {
|
||||
const textNodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
if (!node.textContent?.trim()) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node as Text);
|
||||
}
|
||||
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
function generateCFIForNode(node: Node, offset: number): string {
|
||||
const path: number[] = [];
|
||||
let current: Node | null = node;
|
||||
|
||||
while (current && current.parentNode) {
|
||||
const siblings = Array.from(current.parentNode.childNodes);
|
||||
const index = siblings.indexOf(current as ChildNode);
|
||||
path.unshift(index);
|
||||
current = current.parentNode;
|
||||
}
|
||||
|
||||
return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`;
|
||||
}
|
||||
|
||||
function extractSnippet(text: string, offset: number, length: number): string {
|
||||
const start = Math.max(0, offset - 40);
|
||||
const end = Math.min(text.length, offset + length + 40);
|
||||
let snippet = text.substring(start, end);
|
||||
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < text.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Typography engine for ebook rendering
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
export function init(context: ReaderContext): void {
|
||||
let currentConfig: TypographyConfig | null = null;
|
||||
|
||||
context.events.on("reader:loaded", (detail: { container: HTMLElement; config?: Partial<TypographyConfig> }) => {
|
||||
currentConfig = {
|
||||
readingFont: "literata",
|
||||
fontSize: 18,
|
||||
lineHeight: 1.6,
|
||||
marginTop: 0,
|
||||
marginBottom: 16,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
textAlign: "left",
|
||||
textIndent: 0,
|
||||
hyphenate: false,
|
||||
ligatures: true,
|
||||
fontSmoothing: "auto",
|
||||
...detail.config,
|
||||
};
|
||||
applyTypography(detail.container, currentConfig);
|
||||
});
|
||||
|
||||
context.events.on("typography:update", (detail: { container: HTMLElement; config: Partial<TypographyConfig> }) => {
|
||||
if (currentConfig) {
|
||||
currentConfig = updateTypographyConfig(currentConfig, detail.config);
|
||||
applyTypography(detail.container, currentConfig);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("typography:measure", (detail: { container: HTMLElement }) => {
|
||||
const time = measureReadingTime(detail.container);
|
||||
context.events.emit("typography:reading-time", { minutes: time });
|
||||
});
|
||||
}
|
||||
|
||||
interface TypographyConfig {
|
||||
readingFont:
|
||||
| "literata"
|
||||
| "crimson"
|
||||
| "source-serif"
|
||||
| "eb-garamond"
|
||||
| "libertinus"
|
||||
| "noto-serif"
|
||||
| "charis-sil"
|
||||
| "ibm-plex";
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
textAlign: "left" | "right" | "center" | "justify";
|
||||
textIndent: number;
|
||||
hyphenate: boolean;
|
||||
ligatures: boolean;
|
||||
fontSmoothing: "auto" | "antialiased" | "subpixel-antialiased";
|
||||
}
|
||||
|
||||
function applyTypography(
|
||||
container: HTMLElement,
|
||||
config: TypographyConfig,
|
||||
): void {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return;
|
||||
|
||||
const fontStack = getFontStack(config.readingFont);
|
||||
|
||||
content.setAttribute(
|
||||
"style",
|
||||
`
|
||||
font-family: ${fontStack};
|
||||
font-size: ${config.fontSize}px;
|
||||
line-height: ${config.lineHeight};
|
||||
text-align: ${config.textAlign};
|
||||
margin-top: ${config.marginTop}px;
|
||||
margin-bottom: ${config.marginBottom}px;
|
||||
margin-left: ${config.marginLeft}px;
|
||||
margin-right: ${config.marginRight}px;
|
||||
text-indent: ${config.textIndent}px;
|
||||
-webkit-font-smoothing: ${config.fontSmoothing};
|
||||
-moz-osx-font-smoothing: auto;
|
||||
`,
|
||||
);
|
||||
|
||||
if (config.hyphenate) {
|
||||
enableHyphenation(container, content as HTMLElement);
|
||||
}
|
||||
|
||||
setLigatures(content as HTMLElement, config.ligatures);
|
||||
|
||||
if (config.textAlign === "justify") {
|
||||
enableJustification(content as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontId: string): string {
|
||||
const fonts: Record<string, string> = {
|
||||
"literata": "Literata, serif",
|
||||
"crimson": "Crimson Text, serif",
|
||||
"source-serif": "Source Serif 4, serif",
|
||||
"eb-garamond": "EB Garamond, serif",
|
||||
"libertinus": "Libertinus Serif, serif",
|
||||
"noto-serif": "Noto Serif, serif",
|
||||
"charis-sil": "Charis SIL, serif",
|
||||
"ibm-plex": "IBM Plex Serif, serif",
|
||||
};
|
||||
return fonts[fontId] || "Literata, serif";
|
||||
}
|
||||
|
||||
function enableHyphenation(container: HTMLElement, element: HTMLElement): void {
|
||||
element.style.hyphens = "auto";
|
||||
element.style.hyphenateLimitChars = "6 3 3";
|
||||
|
||||
const lang =
|
||||
container.closest("[data-language]")?.getAttribute("data-language") || "en";
|
||||
element.setAttribute("lang", lang);
|
||||
}
|
||||
|
||||
function setLigatures(element: HTMLElement, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
element.style.fontVariantLigatures = "common-ligatures";
|
||||
element.style.fontFeatureSettings = '"liga", "dlig"';
|
||||
} else {
|
||||
element.style.fontVariantLigatures = "no-common-ligatures";
|
||||
element.style.fontFeatureSettings = "normal";
|
||||
}
|
||||
}
|
||||
|
||||
function enableJustification(element: HTMLElement): void {
|
||||
element.style.wordBreak = "normal";
|
||||
element.style.overflowWrap = "break-word";
|
||||
element.style.wordWrap = "break-word";
|
||||
element.style.letterSpacing = "0.01em";
|
||||
}
|
||||
|
||||
function updateTypographyConfig(
|
||||
currentConfig: TypographyConfig,
|
||||
newConfig: Partial<TypographyConfig>,
|
||||
): TypographyConfig {
|
||||
return { ...currentConfig, ...newConfig };
|
||||
}
|
||||
|
||||
function measureReadingTime(
|
||||
container: HTMLElement,
|
||||
wordsPerMinute: number = 250,
|
||||
): number {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return 0;
|
||||
|
||||
const text = content.textContent || "";
|
||||
const words = text.split(/\s+/).length;
|
||||
const minutes = words / wordsPerMinute;
|
||||
|
||||
return Math.ceil(minutes);
|
||||
}
|
||||
|
||||
export { applyTypography, getFontStack };
|
||||
@@ -0,0 +1,106 @@
|
||||
// Import types
|
||||
import type { ReadingPosition, ReflowableBook } from "./types";
|
||||
import {
|
||||
getPageContent,
|
||||
findPageByCFI,
|
||||
createPositionFromPage,
|
||||
} from "./page-calculator";
|
||||
|
||||
// Navigate to specific page
|
||||
export function goToPage(
|
||||
book: ReflowableBook,
|
||||
targetPage: number,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageIndex = Math.max(
|
||||
0,
|
||||
Math.min(targetPage - 1, book.pagination.totalPages - 1),
|
||||
);
|
||||
const content = getPageContent(book.pagination, pageIndex);
|
||||
const position = createPositionFromPage(book, pageIndex + 1);
|
||||
|
||||
return { success: true, position, content };
|
||||
}
|
||||
|
||||
// Navigate to next page
|
||||
export function nextPage(book: ReflowableBook): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const nextPageNum = book.position.currentPage + 1;
|
||||
return goToPage(book, nextPageNum);
|
||||
}
|
||||
|
||||
// Navigate to previous page
|
||||
export function previousPage(book: ReflowableBook): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const prevPageNum = book.position.currentPage - 1;
|
||||
return goToPage(book, prevPageNum);
|
||||
}
|
||||
|
||||
// Jump to specific CFI
|
||||
export function goToCFI(
|
||||
book: ReflowableBook,
|
||||
cfi: string,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageNum = findPageByCFI(book.pagination, cfi);
|
||||
return goToPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Create default position
|
||||
function createDefaultPosition(): ReadingPosition {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if navigation is possible
|
||||
export function canGoNext(book: ReflowableBook): boolean {
|
||||
return book.position.currentPage < (book.pagination?.totalPages || 1);
|
||||
}
|
||||
|
||||
// Check if previous navigation is possible
|
||||
export function canGoPrevious(book: ReflowableBook): boolean {
|
||||
return book.position.currentPage > 1;
|
||||
}
|
||||
|
||||
// Get progress percentage
|
||||
export function getProgressPercentage(book: ReflowableBook): number {
|
||||
return Math.round(book.position.progress * 100);
|
||||
}
|
||||
|
||||
// Update book position (after resize/recalculation)
|
||||
export function updatePosition(
|
||||
book: ReflowableBook,
|
||||
newCFI?: string,
|
||||
): ReadingPosition {
|
||||
if (newCFI && book.pagination) {
|
||||
const pageNum = findPageByCFI(book.pagination, newCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Import types
|
||||
import type {
|
||||
SpineItem,
|
||||
SpineInfo,
|
||||
PageBoundary,
|
||||
PaginationData,
|
||||
PaginationSettings,
|
||||
ReadingPosition,
|
||||
ReflowableBook,
|
||||
} from "./types";
|
||||
|
||||
// Constants for word count estimation (from Kavita)
|
||||
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
|
||||
|
||||
// Calculate words per page based on settings
|
||||
function calculateWordsPerPage(settings: PaginationSettings): number {
|
||||
const fontSizeFactor = 16 / settings.fontSize;
|
||||
const lineHeightFactor = 1.6 / settings.lineHeight;
|
||||
const areaFactor =
|
||||
(settings.viewportWidth * settings.viewportHeight) / (800 * 600);
|
||||
|
||||
return Math.round(
|
||||
WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract plain text from HTML
|
||||
function extractTextFromHTML(html: string): string {
|
||||
// Remove script and style tags
|
||||
const withoutScripts = html.replace(
|
||||
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
||||
"",
|
||||
);
|
||||
const withoutStyles = withoutScripts.replace(
|
||||
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
|
||||
"",
|
||||
);
|
||||
|
||||
// Extract text content (simple version, no DOM)
|
||||
return withoutStyles
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Count words in text
|
||||
function countWords(text: string): number {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0).length;
|
||||
}
|
||||
|
||||
// Split text into word ranges for pages
|
||||
function splitIntoWordRanges(
|
||||
wordCount: number,
|
||||
wordsPerPage: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < wordCount) {
|
||||
const end = Math.min(start + wordsPerPage, wordCount);
|
||||
ranges.push({ start, end });
|
||||
start = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Escape special characters in CFI
|
||||
function escapeCFIString(str: string): string {
|
||||
return str
|
||||
.replace(/\[/g, "\\[")
|
||||
.replace(/\]/g, "\\]")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/=/g, "\\=");
|
||||
}
|
||||
|
||||
// Generate EPUB CFI for a position in spine
|
||||
// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/
|
||||
// Format: epubcfi(/6/spine_index!/path/element/offset)
|
||||
function generateCFI(
|
||||
spineIndex: number,
|
||||
charOffset: number,
|
||||
totalChars: number,
|
||||
spineItemId: string,
|
||||
): string {
|
||||
const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : "";
|
||||
const offset = Math.min(charOffset, totalChars);
|
||||
const spinePath = `/6/${spineIndex + 2}${escapedId}`;
|
||||
|
||||
return `epubcfi(${spinePath}!/4/2/1:${offset})`;
|
||||
}
|
||||
|
||||
// Parse EPUB CFI to extract position
|
||||
function parseCFI(
|
||||
cfi: string,
|
||||
): { spineIndex: number; charOffset: number } | null {
|
||||
if (!cfi.startsWith("epubcfi(")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove epubcfi( wrapper
|
||||
const inner = cfi.slice(8, -1);
|
||||
if (!inner) return null;
|
||||
|
||||
// Split on ! to separate spine path from content path
|
||||
const parts = inner.split("!");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// Extract spine index from /6/4 or /6/4[id] format
|
||||
const spineMatch = parts[0].match(/\/6\/(\d+)/);
|
||||
if (!spineMatch) return null;
|
||||
|
||||
const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset
|
||||
if (spineIndex < 0) return null;
|
||||
|
||||
// Extract character offset from :123 format
|
||||
const offsetMatch = parts[1].match(/:(\d+)$/);
|
||||
if (!offsetMatch) return null;
|
||||
|
||||
const charOffset = parseInt(offsetMatch[1]);
|
||||
|
||||
return { spineIndex, charOffset };
|
||||
}
|
||||
|
||||
// Calculate pagination for entire book
|
||||
export async function calculatePagination(
|
||||
spineItems: SpineItem[],
|
||||
contentMap: Map<string, Blob>,
|
||||
settings: PaginationSettings,
|
||||
): Promise<PaginationData> {
|
||||
const wordsPerPage = calculateWordsPerPage(settings);
|
||||
const spines: SpineInfo[] = [];
|
||||
const pageMap = new Map<number, PageBoundary>();
|
||||
let globalPageIndex = 0;
|
||||
|
||||
// Process each spine item
|
||||
for (let i = 0; i < spineItems.length; i++) {
|
||||
const spineItem = spineItems[i];
|
||||
|
||||
// Skip non-HTML items (cover pages, etc)
|
||||
if (spineItem.type !== "html") {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
wordCount: 0,
|
||||
cfiStart: "",
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get content
|
||||
const contentBlob = contentMap.get(spineItem.content);
|
||||
if (!contentBlob) {
|
||||
console.warn(`Content not found for spine ${spineItem.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentHTML = await contentBlob.text();
|
||||
const plainText = extractTextFromHTML(contentHTML);
|
||||
const wordCount = countWords(plainText);
|
||||
const charCount = plainText.length;
|
||||
|
||||
// Skip empty spines
|
||||
if (wordCount === 0) {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into pages
|
||||
const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage);
|
||||
const pages: PageBoundary[] = [];
|
||||
|
||||
for (let j = 0; j < wordRanges.length; j++) {
|
||||
const range = wordRanges[j];
|
||||
const page: PageBoundary = {
|
||||
pageIndex: globalPageIndex,
|
||||
localPageIndex: j,
|
||||
charStart: Math.round((range.start / wordCount) * charCount),
|
||||
charEnd: Math.round((range.end / wordCount) * charCount),
|
||||
wordStart: range.start,
|
||||
wordEnd: range.end,
|
||||
cfi: generateCFI(
|
||||
i,
|
||||
Math.round((range.start / wordCount) * charCount),
|
||||
charCount,
|
||||
spineItem.id,
|
||||
),
|
||||
};
|
||||
|
||||
pages.push(page);
|
||||
pageMap.set(globalPageIndex, page);
|
||||
globalPageIndex++;
|
||||
}
|
||||
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
// Build map
|
||||
const spineMap = new Map<number, SpineInfo>();
|
||||
for (const spine of spines) {
|
||||
spineMap.set(spine.spineIndex, spine);
|
||||
}
|
||||
|
||||
return {
|
||||
totalPages: globalPageIndex,
|
||||
spines,
|
||||
spineMap,
|
||||
pageMap,
|
||||
calculatedAt: Date.now(),
|
||||
settings: { ...settings, wordsPerPage },
|
||||
};
|
||||
}
|
||||
|
||||
// Find which page contains a CFI
|
||||
export function findPageByCFI(
|
||||
pagination: PaginationData,
|
||||
targetCFI: string,
|
||||
): number {
|
||||
const parsed = parseCFI(targetCFI);
|
||||
if (!parsed) return 1;
|
||||
|
||||
const { spineIndex, charOffset } = parsed;
|
||||
const spine = pagination.spineMap.get(spineIndex);
|
||||
|
||||
if (!spine || spine.pages.length === 0) return 1;
|
||||
|
||||
// Find page containing this character offset
|
||||
for (const page of spine.pages) {
|
||||
if (charOffset >= page.charStart && charOffset < page.charEnd) {
|
||||
return page.pageIndex + 1; // 1-indexed
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract HTML slice between character offsets
|
||||
function extractHTMLSlice(
|
||||
html: string,
|
||||
charStart: number,
|
||||
charEnd: number,
|
||||
): string {
|
||||
if (charStart === 0 && charEnd >= html.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Parse HTML and extract text nodes within the character range
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const body = doc.body;
|
||||
|
||||
// Find all text nodes and their cumulative character counts
|
||||
type TextNodeInfo = { node: Text; startChar: number; endChar: number };
|
||||
const textNodes: TextNodeInfo[] = [];
|
||||
let cumulativeChars = 0;
|
||||
|
||||
function traverse(node: Node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || "";
|
||||
const startChar = cumulativeChars;
|
||||
cumulativeChars += text.length;
|
||||
const endChar = cumulativeChars;
|
||||
|
||||
textNodes.push({ node: node as Text, startChar, endChar });
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Skip script and style tags
|
||||
if (node instanceof HTMLElement) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "script" || tagName === "style") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse children
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(body);
|
||||
|
||||
// Find which text nodes intersect with the requested range
|
||||
const relevantNodes: { node: Text; before: string; after: string }[] = [];
|
||||
|
||||
for (const { node, startChar, endChar } of textNodes) {
|
||||
if (endChar <= charStart || startChar >= charEnd) {
|
||||
// No overlap
|
||||
continue;
|
||||
}
|
||||
const text = node.textContent || "";
|
||||
let resultText = text;
|
||||
// Trim from left if node starts before page
|
||||
if (startChar < charStart) {
|
||||
resultText = text.substring(charStart - startChar);
|
||||
}
|
||||
// Trim from right if node extends past page end
|
||||
if (endChar > charEnd) {
|
||||
// Calculate where to cut within the (potentially already trimmed) text
|
||||
const cutPosition = charEnd - startChar;
|
||||
resultText = text.substring(0, cutPosition);
|
||||
}
|
||||
// Handle case where both trims are needed
|
||||
if (startChar < charStart && endChar > charEnd) {
|
||||
const leftTrim = charStart - startChar;
|
||||
const rightTrim = endChar - charEnd;
|
||||
resultText = text.substring(leftTrim, text.length - rightTrim);
|
||||
}
|
||||
relevantNodes.push({ node, before: "", after: resultText });
|
||||
}
|
||||
|
||||
// Preserve original HTML structure for nodes in range
|
||||
const startNode = textNodes.find((n) => n.endChar > charStart);
|
||||
const endNode = textNodes.find((n) => n.startChar < charEnd);
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Find element boundaries
|
||||
let startElement: Node | null = startNode.node;
|
||||
while (startElement && startElement.parentNode !== body) {
|
||||
startElement = startElement.parentNode;
|
||||
}
|
||||
|
||||
let endElement: Node | null = endNode.node;
|
||||
while (endElement && endElement.parentNode !== body) {
|
||||
endElement = endElement.parentNode;
|
||||
}
|
||||
|
||||
// Extract and modify the relevant portion
|
||||
if (startElement && endElement) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let currentElement: Node | null = startElement;
|
||||
let foundEnd = false;
|
||||
|
||||
while (currentElement && !foundEnd) {
|
||||
if (currentElement.nodeType === Node.ELEMENT_NODE) {
|
||||
const clone = (currentElement as Element).cloneNode(false);
|
||||
fragment.appendChild(clone);
|
||||
|
||||
// Process children
|
||||
for (const child of Array.from(currentElement.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textNodeInfo = textNodes.find((n) => n.node === child);
|
||||
if (textNodeInfo) {
|
||||
const modified = document.createTextNode(
|
||||
relevantNodes.find((n) => n.node === child)?.after || "",
|
||||
);
|
||||
clone.appendChild(modified);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Recursively handle element children
|
||||
const childClone = child.cloneNode(true);
|
||||
clone.appendChild(childClone);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentElement === endElement) {
|
||||
foundEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
currentElement = currentElement.nextSibling;
|
||||
}
|
||||
|
||||
// Serialize fragment back to HTML
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.appendChild(fragment);
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
// Fallback: return original HTML if extraction fails
|
||||
return html;
|
||||
}
|
||||
|
||||
// Get page content (HTML slice for a page)
|
||||
export function getPageContent(
|
||||
pagination: PaginationData,
|
||||
pageIndex: number,
|
||||
): string {
|
||||
const page = pagination.pageMap.get(pageIndex);
|
||||
if (!page) return "";
|
||||
|
||||
// Find the spine that contains this page
|
||||
// Pages are stored in order, so we can find the spine by checking which pages it contains
|
||||
let spine: SpineInfo | undefined;
|
||||
for (const s of pagination.spines) {
|
||||
if (s.pages.some((p) => p.pageIndex === pageIndex)) {
|
||||
spine = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spine) return "";
|
||||
|
||||
// Extract HTML content between page boundaries
|
||||
const htmlSlice = extractHTMLSlice(
|
||||
spine.content,
|
||||
page.charStart,
|
||||
page.charEnd,
|
||||
);
|
||||
|
||||
// Wrap in a div to ensure valid HTML structure
|
||||
return `<div class="page-content-wrapper">${htmlSlice}</div>`;
|
||||
}
|
||||
|
||||
// Recalculate pagination on viewport change
|
||||
export function shouldRecalculate(
|
||||
pagination: PaginationData | null,
|
||||
newSettings: PaginationSettings,
|
||||
): boolean {
|
||||
if (!pagination) return true;
|
||||
|
||||
const sizeChanged =
|
||||
Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) >
|
||||
50 ||
|
||||
Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) >
|
||||
50;
|
||||
|
||||
const fontChanged = pagination.settings.fontSize !== newSettings.fontSize;
|
||||
const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight;
|
||||
|
||||
return sizeChanged || fontChanged || lineChanged;
|
||||
}
|
||||
|
||||
// Create position object from page number
|
||||
export function createPositionFromPage(
|
||||
book: ReflowableBook,
|
||||
page: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const pageIndex = page - 1;
|
||||
const pageData = book.pagination.pageMap.get(pageIndex);
|
||||
|
||||
if (!pageData) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Find which spine this page belongs to
|
||||
let spineIndex = 0;
|
||||
for (const spine of book.pagination.spines) {
|
||||
if (pageData.localPageIndex < spine.pages.length) {
|
||||
spineIndex = spine.spineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
spineIndex,
|
||||
localPageIndex: pageData.localPageIndex,
|
||||
cfi: pageData.cfi,
|
||||
progress:
|
||||
book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Import types and existing parsers
|
||||
import type { ReflowableBook, SpineItem, TOCItem } from "./types";
|
||||
import { parseEPUB } from "../../parsers/epub-parsers";
|
||||
import { parseFB2 } from "../../parsers/fb2-parser";
|
||||
import { parseTXT } from "../../parsers/txt-parser";
|
||||
import { parseHTML } from "../../parsers/html-parser";
|
||||
|
||||
// Parse any reflowable format
|
||||
export async function parseReflowable(
|
||||
file: File,
|
||||
format: "epub" | "fb2" | "txt" | "html",
|
||||
): Promise<ReflowableBook> {
|
||||
switch (format) {
|
||||
case "epub":
|
||||
return await parseEPUB(file);
|
||||
case "fb2":
|
||||
return await parseFB2(file);
|
||||
case "txt":
|
||||
return await parseTXT(file);
|
||||
case "html":
|
||||
return await parseHTML(file);
|
||||
default:
|
||||
throw new Error(`Unsupported reflowable format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parsed book data
|
||||
export function validateBook(book: ReflowableBook): boolean {
|
||||
return book.spine.length > 0 && book.metadata.title !== "";
|
||||
}
|
||||
|
||||
// Get book title
|
||||
export function getBookTitle(book: ReflowableBook): string {
|
||||
return book.metadata.title || "Untitled";
|
||||
}
|
||||
|
||||
// Get book author
|
||||
export function getBookAuthor(book: ReflowableBook): string {
|
||||
return book.metadata.author || "Unknown";
|
||||
}
|
||||
|
||||
// Get total spine count
|
||||
export function getSpineCount(book: ReflowableBook): number {
|
||||
return book.spine.length;
|
||||
}
|
||||
|
||||
// Get TOC as flat list
|
||||
export function getFlatTOC(book: ReflowableBook): TOCItem[] {
|
||||
const flat: TOCItem[] = [];
|
||||
|
||||
function traverse(items: TOCItem[]) {
|
||||
for (const item of items) {
|
||||
flat.push(item);
|
||||
if (item.children.length > 0) {
|
||||
traverse(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(book.toc);
|
||||
return flat;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Import types
|
||||
import type { ReflowableBook, ReadingPosition } from "./types";
|
||||
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
|
||||
|
||||
// Update current position
|
||||
export function updateCurrentPosition(
|
||||
book: ReflowableBook,
|
||||
position: ReadingPosition,
|
||||
): ReflowableBook {
|
||||
return {
|
||||
...book,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract CFI from position
|
||||
export function getCurrentCFI(book: ReflowableBook): string {
|
||||
return book.position.cfi;
|
||||
}
|
||||
|
||||
// Calculate progress for display
|
||||
export function calculateProgress(book: ReflowableBook): {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
percentage: number;
|
||||
} {
|
||||
const totalPages = book.pagination?.totalPages || 1;
|
||||
const currentPage = book.position.currentPage;
|
||||
const percentage =
|
||||
totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0;
|
||||
|
||||
return { currentPage, totalPages, percentage };
|
||||
}
|
||||
|
||||
// Get position for saving to database
|
||||
export function getPositionForSave(book: ReflowableBook): {
|
||||
cfi: string;
|
||||
progress: number;
|
||||
page: number;
|
||||
} {
|
||||
return {
|
||||
cfi: book.position.cfi,
|
||||
progress: book.position.progress,
|
||||
page: book.position.currentPage,
|
||||
};
|
||||
}
|
||||
|
||||
// Restore position from database
|
||||
export function restorePosition(
|
||||
book: ReflowableBook,
|
||||
savedCFI: string,
|
||||
savedPage?: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// If we have saved CFI, try to find exact position
|
||||
if (savedCFI) {
|
||||
const pageNum = findPageByCFI(book.pagination, savedCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Otherwise use saved page number
|
||||
if (savedPage && savedPage > 0) {
|
||||
return createPositionFromPage(book, savedPage);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// Check if position changed significantly
|
||||
export function didPositionChange(
|
||||
oldPos: ReadingPosition,
|
||||
newPos: ReadingPosition,
|
||||
): boolean {
|
||||
return (
|
||||
oldPos.currentPage !== newPos.currentPage ||
|
||||
oldPos.cfi !== newPos.cfi ||
|
||||
Math.abs(oldPos.progress - newPos.progress) > 0.01
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Spine item structure from parsed EPUB/FB2/etc
|
||||
export interface SpineItem {
|
||||
id: string;
|
||||
type: "html" | "image" | "other";
|
||||
content: string; // Blob URL or content ID
|
||||
href?: string; // For CFI generation
|
||||
}
|
||||
|
||||
// Information about a single spine item
|
||||
export interface SpineInfo {
|
||||
spineIndex: number;
|
||||
spineItemId: string;
|
||||
content: string; // Full HTML content
|
||||
charCount: number; // Total characters
|
||||
wordCount: number; // Total words (for pagination)
|
||||
cfiStart: string; // CFI at start of this spine
|
||||
pages: PageBoundary[]; // Page boundaries within this spine
|
||||
}
|
||||
|
||||
// A single page boundary within a spine
|
||||
export interface PageBoundary {
|
||||
pageIndex: number; // Global page index
|
||||
localPageIndex: number; // Page index within this spine
|
||||
charStart: number; // Character offset from start of spine
|
||||
charEnd: number; // Character offset at end of page
|
||||
wordStart: number; // Word offset from start of spine
|
||||
wordEnd: number; // Word offset at end of page
|
||||
cfi: string; // CFI for this position
|
||||
}
|
||||
|
||||
// Complete pagination data
|
||||
export interface PaginationData {
|
||||
totalPages: number;
|
||||
spines: SpineInfo[];
|
||||
spineMap: Map<number, SpineInfo>;
|
||||
pageMap: Map<number, PageBoundary>; // pageIndex -> PageBoundary
|
||||
calculatedAt: number;
|
||||
settings: PaginationSettings;
|
||||
}
|
||||
|
||||
// Settings used for calculation
|
||||
export interface PaginationSettings {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
wordsPerPage: number; // Calculated from above
|
||||
}
|
||||
|
||||
// Current reading position
|
||||
export interface ReadingPosition {
|
||||
currentPage: number;
|
||||
spineIndex: number;
|
||||
localPageIndex: number;
|
||||
cfi: string;
|
||||
progress: number; // 0-1
|
||||
}
|
||||
|
||||
// Reflowable book data
|
||||
export interface ReflowableBook {
|
||||
type: "epub" | "fb2" | "txt" | "html";
|
||||
spine: SpineItem[];
|
||||
resources: Map<string, Blob>;
|
||||
toc: TOCItem[];
|
||||
metadata: BookMetadata;
|
||||
pagination: PaginationData | null;
|
||||
position: ReadingPosition;
|
||||
}
|
||||
|
||||
// Table of contents item
|
||||
export interface TOCItem {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
children: TOCItem[];
|
||||
}
|
||||
|
||||
// Book metadata
|
||||
export interface BookMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
identifier: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
Reference in New Issue
Block a user