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:
2026-04-09 14:53:12 -04:00
parent 07ec8afa5f
commit e4c18e51f9
42 changed files with 5678 additions and 0 deletions
@@ -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;
}