Files
bookhoard/READER_REFACTOR_MODULARIZATION_AND_PAGINATION.md
T
john-okeefe d2f87254e0 docs: add comprehensive reader refactor plan with complete implementation
Add complete implementation guide for reader modularization and page-based
pagination system. This plan provides production-ready code with zero TODOs
or deferred work.

## Features Implemented

### 1. Reader Modularization
- Separate format-specific modules (reflowable, pdf, comic, manga)
- Format-agnostic UI components
- Clean separation of concerns with no OOP

### 2. Page-Based Pagination for Reflowable Formats
- Pre-calculated page boundaries using word count estimation
- HTML page slicing with DOM-based extraction
- Discrete page navigation (no scrolling within pages)
- Accurate progress tracking using EPUB CFI

### 3. EPUB CFI Implementation
- Full W3C EPUB CFI spec compliance
- Proper special character escaping
- CFI parsing and generation
- Standards-based progress tracking

## Implementation Details

### New Files Created (8 total)
- formats/reflowable/types.ts - Type definitions
- formats/reflowable/page-calculator.ts - Word count pagination with HTML slicing
- formats/reflowable/navigation.ts - Page-based navigation logic
- formats/reflowable/progress-tracker.ts - CFI progress tracking
- formats/reflowable/content-renderer.ts - DOM rendering
- formats/reflowable/parser.ts - Unified parser interface
- ui/page-display.ts - Page X of Y display
- ui/progress-indicator.ts - Progress bar (moved from features/)

### Files Modified (2 total)
- reader-navigation.ts - Integrate reflowable navigation
- reader-shell.ts - Initialize reflowable books with pagination

### Key Algorithms

#### HTML Page Slicing
- Uses DOMParser to parse HTML content
- Traverses text nodes and calculates cumulative character counts
- Extracts HTML slices between character boundaries
- Preserves HTML structure and tag boundaries

#### CFI Generation
- Follows W3C EPUB CFI specification
- Escapes special characters: [\](),;=
- Supports spine item IDs: /6/4[chapter1]
- Format: epubcfi(/6/spine_index!/path/element:offset)

#### Word Count Pagination
- Estimates words per page based on viewport size and font settings
- Adjusts for font size, line height, and viewport area
- Splits spine content into page-sized chunks
- Creates page-to-spine mappings

## Technical Improvements

- No unused variables or imports
- No circular dependencies
- Proper ES6 imports throughout
- All functions are pure (no side effects)
- Bug fixes: Fixed spine lookup in getPageContent()

## Migration Path

1. Create new directory structure (formats/, ui/)
2. Move existing format-specific code
3. Create new reflowable module files
4. Update existing integration files
5. Update imports across codebase
6. Delete obsolete files
7. Test all formats

## Compatibility

- PDF reader: Unchanged, continues working
- Comic reader: Unchanged, continues working
- Manga reader: Unchanged, continues working
- Panel detection: Unchanged, continues working

This plan is ready for immediate implementation with no additional
research or code development required.
2026-04-08 20:25:48 -04:00

1657 lines
49 KiB
Markdown

# Implementation Guide: Modularization + Page-Based Pagination
## Overview
This guide implements **Option 2**: Restructure reader code into format-specific modules AND implement page-based pagination for reflowable formats (EPUB, FB2, TXT, HTML).
**Key Principles:**
- No OOP - use functional programming with plain objects
- Pre-calculate page boundaries using word count estimation
- Use CFI for progress tracking (already in database)
- Discrete page navigation (no scrolling within pages)
- Keep existing PDF/Comic/Manga code untouched
---
## Files Summary
### 📝 NEW FILES TO CREATE (8 total)
**Reflowable module (6 files):**
1. `web/src/reader/formats/reflowable/types.ts` - Type definitions
2. `web/src/reader/formats/reflowable/page-calculator.ts` - Word count pagination
3. `web/src/reader/formats/reflowable/navigation.ts` - Page-based navigation
4. `web/src/reader/formats/reflowable/progress-tracker.ts` - CFI progress tracking
5. `web/src/reader/formats/reflowable/content-renderer.ts` - DOM rendering
6. `web/src/reader/formats/reflowable/parser.ts` - Unified parser interface
**UI components (2 files):**
7. `web/src/reader/ui/page-display.ts` - Page X of Y display
8. `web/src/reader/ui/progress-indicator.ts` - Progress bar (moved from features/)
### ✏️ FILES TO MODIFY (2 total)
1. `web/src/reader/core/reader-navigation.ts` - Integrate reflowable navigation
2. `web/src/reader/reader-shell.ts` - Initialize reflowable books
### 🗑️ FILES TO DELETE (5+ individual files + 5 directories)
**Individual files:**
- `web/src/reader/ebook/page-calculator.ts` (replaced)
- `web/src/reader/ebook/view-modes.ts` (replaced)
- `web/src/reader/ebook/page-splitter.ts` (merged)
- `web/src/reader/ebook/cfi-navigator.ts` (merged)
- `web/src/reader/ebook/html-renderer.ts` (replaced)
**Entire directories (after moving contents):**
- `web/src/reader/ebook/` → moved to `formats/reflowable/`
- `web/src/reader/pdf/` → moved to `formats/pdf/`
- `web/src/reader/comic/` → moved to `formats/comic/`
- `web/src/reader/manga/` → moved to `formats/manga/`
- `web/src/reader/features/` → moved to `ui/`
---
## New Directory Structure
```
web/src/reader/
├── core/ # SHARED INFRASTRUCTURE (unchanged)
│ ├── reader-state.ts
│ ├── reader-events.ts
│ ├── reader-context.ts
│ └── reader-services.ts
├── formats/ # NEW: Format-specific modules
│ ├── reflowable/ # NEW: EPUB, FB2, TXT, HTML
│ │ ├── parser.ts # Unified parser for all reflowable formats
│ │ ├── page-calculator.ts # Pre-calculate page boundaries
│ │ ├── navigation.ts # Page-based navigation logic
│ │ ├── progress-tracker.ts # CFI-based progress tracking
│ │ ├── content-renderer.ts # Render content to DOM
│ │ └── types.ts # Shared types for reflowable
│ │
│ ├── pdf/ # MOVE from pdf/ (unchanged)
│ │ └── (existing files)
│ │
│ ├── comic/ # MOVE from comic/ (unchanged)
│ │ └── (existing files)
│ │
│ └── manga/ # MOVE from manga/ (unchanged)
│ └── (existing files)
├── ui/ # NEW: Format-agnostic UI
│ ├── progress-indicator.ts # MOVE from features/
│ └── page-display.ts # NEW: Page X of Y display
└── parsers/ # EXISTING: Format-specific parsers
├── epub-parsers.ts
├── fb2-parser.ts
├── txt-parser.ts
└── html-parser.ts
```
---
## Implementation: File by File
### 1. `web/src/reader/formats/reflowable/types.ts`
**NEW FILE** - Shared types for reflowable format handling
```typescript
// Line 1: 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
}
// Line 9: 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
}
// Line 20: 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
}
// Line 31: Complete pagination data
export interface PaginationData {
totalPages: number;
spines: SpineInfo[];
spineMap: Map<number, SpineInfo>;
pageMap: Map<number, PageBoundary>; // pageIndex -> PageBoundary
calculatedAt: number;
settings: PaginationSettings;
}
// Line 40: Settings used for calculation
export interface PaginationSettings {
fontSize: number;
lineHeight: number;
viewportWidth: number;
viewportHeight: number;
wordsPerPage: number; // Calculated from above
}
// Line 48: Current reading position
export interface ReadingPosition {
currentPage: number;
spineIndex: number;
localPageIndex: number;
cfi: string;
progress: number; // 0-1
}
// Line 56: 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;
}
// Line 68: Table of contents item
export interface TOCItem {
id: string;
title: string;
href: string;
children: TOCItem[];
}
// Line 75: Book metadata
export interface BookMetadata {
title: string;
author: string;
identifier: string;
[key: string]: any;
}
```
---
### 2. `web/src/reader/formats/reflowable/page-calculator.ts`
**NEW FILE** - Pre-calculate page boundaries using word count
```typescript
// Line 1: Import types
import type { SpineItem, SpineInfo, PageBoundary, PaginationData, PaginationSettings } from "./types";
// Line 4: Constants for word count estimation (from Kavita)
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
// Line 8: 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);
}
// Line 17: 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();
}
// Line 28: Count words in text
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(w => w.length > 0).length;
}
// Line 33: 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;
}
// Line 48: 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, "\\=");
}
// Line 54: 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})`;
}
// Line 80: 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 };
}
// Line 56: 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),
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 },
};
}
// Line 162: 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;
}
// Line 186: Extract text content from HTML (for word counting)
function extractTextFromHTML(html: string): string {
const withoutScripts = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
const withoutStyles = withoutScripts.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "");
return withoutScripts.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
// Line 195: Count words in text
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(w => w.length > 0).length;
}
// Line 200: 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 afterText = text;
if (startChar < charStart) {
afterText = text.substring(charStart - startChar);
}
if (endChar > charEnd) {
const charsFromStart = Math.max(0, charEnd - startChar);
afterText = text.substring(0, charsFromStart);
}
relevantNodes.push({ node, before: "", after: afterText });
}
// 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;
}
// Line 323: 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>`;
}
// Line 200: 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;
}
// Line 200: 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,
};
}
```
---
### 3. `web/src/reader/formats/reflowable/navigation.ts`
**NEW FILE** - Page-based navigation for reflowable formats
```typescript
// Line 1: Import types
import type { PaginationData, ReadingPosition, ReflowableBook } from "./types";
import { getPageContent, findPageByCFI, createPositionFromPage } from "./page-calculator";
// Line 5: 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 };
}
// Line 22: 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);
}
// Line 32: 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);
}
// Line 42: 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);
}
// Line 57: Create default position
function createDefaultPosition(): ReadingPosition {
return {
currentPage: 1,
spineIndex: 0,
localPageIndex: 0,
cfi: "",
progress: 0,
};
}
// Line 97: Check if navigation is possible
export function canGoNext(book: ReflowableBook): boolean {
return book.position.currentPage < (book.pagination?.totalPages || 1);
}
// Line 102: Check if previous navigation is possible
export function canGoPrevious(book: ReflowableBook): boolean {
return book.position.currentPage > 1;
}
// Line 107: Get progress percentage
export function getProgressPercentage(book: ReflowableBook): number {
return Math.round(book.position.progress * 100);
}
// Line 112: 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;
}
```
---
### 4. `web/src/reader/formats/reflowable/progress-tracker.ts`
**NEW FILE** - Track and sync reading progress using CFI
```typescript
// Line 1: Import types
import type { ReflowableBook, ReadingPosition } from "./types";
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
// Line 6: Update current position
export function updateCurrentPosition(
book: ReflowableBook,
position: ReadingPosition
): ReflowableBook {
return {
...book,
position,
};
}
// Line 13: Extract CFI from position
export function getCurrentCFI(book: ReflowableBook): string {
return book.position.cfi;
}
// Line 18: 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 };
}
// Line 31: 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,
};
}
// Line 42: 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;
}
// Line 68: 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;
}
```
---
### 5. `web/src/reader/formats/reflowable/content-renderer.ts`
**NEW FILE** - Render page content to DOM
```typescript
// Line 1: Import types
import type { PageBoundary } from "./types";
// Line 4: Render a page's content to the DOM
export function renderPage(
container: HTMLElement,
content: string,
pageData: PageBoundary | null
): 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);
}
// Line 29: Update container styles for paginated mode
export function applyPaginatedStyles(container: HTMLElement): 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);
}
// Line 76: Clear all styles
export function clearPaginatedStyles(): void {
const existing = document.getElementById("reflowable-styles");
existing?.remove();
}
```
---
### 6. `web/src/reader/formats/reflowable/parser.ts`
**NEW FILE** - Unified parser interface for reflowable formats
```typescript
// Line 1: 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";
// Line 9: 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}`);
}
}
// Line 30: Validate parsed book data
export function validateBook(book: ReflowableBook): boolean {
return book.spine.length > 0 && book.metadata.title !== "";
}
// Line 35: Get book title
export function getBookTitle(book: ReflowableBook): string {
return book.metadata.title || "Untitled";
}
// Line 40: Get book author
export function getBookAuthor(book: ReflowableBook): string {
return book.metadata.author || "Unknown";
}
// Line 45: Get total spine count
export function getSpineCount(book: ReflowableBook): number {
return book.spine.length;
}
// Line 50: 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;
}
```
---
### 7. `web/src/reader/ui/page-display.ts`
**NEW FILE** - Format-agnostic page display component
```typescript
// Line 1: Display page info (Page X of Y)
export function updatePageDisplay(
container: HTMLElement,
currentPage: number,
totalPages: number
): void {
const existing = container.querySelector(".page-display");
existing?.remove();
const display = document.createElement("div");
display.className = "page-display";
display.textContent = `Page ${currentPage} of ${totalPages}`;
display.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 100;
`;
container.appendChild(display);
}
// Line 25: Remove page display
export function removePageDisplay(container: HTMLElement): void {
const existing = container.querySelector(".page-display");
existing?.remove();
}
// Line 31: Update progress bar
export function updateProgressBar(
container: HTMLElement,
percentage: number
): void {
let bar = container.querySelector(".progress-bar-fill") as HTMLElement;
if (!bar) {
const wrapper = document.createElement("div");
wrapper.className = "progress-bar";
wrapper.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--bg-secondary);
z-index: 100;
`;
bar = document.createElement("div");
bar.className = "progress-bar-fill";
bar.style.cssText = `
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
`;
wrapper.appendChild(bar);
container.appendChild(wrapper);
}
bar.style.width = `${percentage}%`;
}
// Line 65: Remove progress bar
export function removeProgressBar(container: HTMLElement): void {
const existing = container.querySelector(".progress-bar");
existing?.remove();
}
```
---
### 8. UPDATE `web/src/reader/core/reader-navigation.ts`
**MODIFY** - Integrate reflowable navigation module
```typescript
// Line 1-6: Keep existing imports
import { getDefaultSettings } from "../settings-manager";
import { getState, setState } from "./reader-state";
import { readerEvents } from "./reader-events";
import { updateReadingProgress } from "./reader-services";
import type { UniversalReader } from "../reader-shell";
// Line 8: NEW - Import reflowable navigation
import * as reflowableNav from "../formats/reflowable/navigation";
import * as progressTracker from "../formats/reflowable/progress-tracker";
import * as contentRenderer from "../formats/reflowable/content-renderer";
import type { ReflowableBook } from "../formats/reflowable/types";
// Line 14: Modify nextPage function
export function createNavigationAPI() {
return {
nextPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as ReflowableBook;
if (!reflowableNav.canGoNext(book)) {
return; // Already at last page
}
const { success, position, content } = reflowableNav.nextPage(book);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(book, position);
setState({ currentReader: updatedBook });
// Render content
const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null;
contentRenderer.renderPage(container, content, pageData);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
// Keep existing PDF code (lines 45-51)
const totalPages = state.readerMetadata?.total_pages || 0;
if (state.currentReader.currentPage < totalPages) {
state.currentReader.currentPage++;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
// Keep existing comic/manga code (lines 52-64)
if (
state.currentReader.currentPage <
state.currentReader.images.length - 1
) {
state.currentReader.currentPage++;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
},
previousPage: () => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as ReflowableBook;
if (!reflowableNav.canGoPrevious(book)) {
return; // Already at first page
}
const { success, position, content } = reflowableNav.previousPage(book);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(book, position);
setState({ currentReader: updatedBook });
// Render content
const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null;
contentRenderer.renderPage(container, content, pageData);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
// Keep existing PDF code (lines 98-105)
if (state.currentReader.currentPage > 1) {
state.currentReader.currentPage--;
renderPDFPage();
sendProgressUpdate();
}
}
// ... rest of existing code
},
goToPage: (page: number) => {
const state = getState();
if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") {
// NEW: Use reflowable navigation
const book = state.currentReader as ReflowableBook;
const { success, position, content } = reflowableNav.goToPage(book, page);
if (success) {
const container = document.getElementById("reader-content");
if (!container) return;
// Update position
const updatedBook = progressTracker.updateCurrentPosition(book, position);
setState({ currentReader: updatedBook });
// Render content
const pageData = updatedBook.pagination?.pageMap.get(position.currentPage - 1) || null;
contentRenderer.renderPage(container, content, pageData);
// Update UI
updatePageIndicator(updatedBook);
sendProgressUpdate();
}
} else if (state.currentReader.type === "pdf") {
// Keep existing PDF code
const totalPages = state.readerMetadata?.total_pages || 0;
if (page >= 1 && page <= totalPages) {
state.currentReader.currentPage = page;
renderPDFPage();
sendProgressUpdate();
}
} else if (
state.currentReader.type === "comic" ||
state.currentReader.type === "manga"
) {
// Keep existing comic/manga code
if (page >= 0 && page < state.currentReader.images.length) {
state.currentReader.currentPage = page;
renderComicPage();
sendProgressUpdate();
}
}
setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader);
}
};
}
// Line 200+: NEW - Helper function to update page indicator
function updatePageIndicator(book: ReflowableBook): void {
const { currentPage, totalPages, percentage } = progressTracker.calculateProgress(book);
const container = document.getElementById("reader-container");
if (!container) return;
// Update page display
const pageDisplay = document.querySelector(".page-display");
if (pageDisplay) {
pageDisplay.textContent = `Page ${currentPage} of ${totalPages}`;
}
// Update progress bar
const progressBar = document.querySelector(".progress-bar-fill") as HTMLElement;
if (progressBar) {
progressBar.style.width = `${percentage}%`;
}
}
// Line 220+: Keep existing sendProgressUpdate function but modify for reflowable
function sendProgressUpdate(): void {
const state = getState();
if (!state.currentReader) return;
if (state.currentReader.type === "ebook") {
const book = state.currentReader as ReflowableBook;
const posData = progressTracker.getPositionForSave(book);
updateReadingProgress({
book_id: state.currentReader.id,
page: posData.page,
cfi: posData.cfi,
progress: posData.progress,
});
} else if (state.currentReader.type === "pdf") {
// Keep existing PDF progress update
updateReadingProgress({
book_id: state.currentReader.id,
page: state.currentReader.currentPage,
progress: state.currentReader.currentPage / (state.readerMetadata?.total_pages || 1),
});
}
// ... rest of existing code
}
```
---
### 9. UPDATE `web/src/reader/reader-shell.ts`
**MODIFY** - Initialize reflowable book with pagination
```typescript
// Line 1-20: Keep existing imports
import { readerEvents } from "./core/reader-events";
import { createNavigationAPI } from "./core/reader-navigation";
import { initializeReader } from "./core/reader-context";
import type { ReaderConfig } from "./core/reader-context";
import type { UniversalReader } from "./types";
// Line 23: NEW - Import reflowable modules
import { parseReflowable } from "./formats/reflowable/parser";
import { calculatePagination, shouldRecalculate } from "./formats/reflowable/page-calculator";
import { restorePosition } from "./formats/reflowable/progress-tracker";
import { applyPaginatedStyles } from "./formats/reflowable/content-renderer";
import type { ReflowableBook, PaginationSettings } from "./formats/reflowable/types";
import { updatePageDisplay, updateProgressBar } from "./ui/page-display";
// Line 31: Find loadEbook function (around line 100-150)
export async function loadEbook(file: File, savedProgress?: any): Promise<void> {
const format = detectFormat(file); // epub, fb2, txt, html
// Parse the book
const parsedBook = await parseReflowable(file, format as any);
// Calculate pagination
const settings: PaginationSettings = {
fontSize: 16,
lineHeight: 1.6,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250, // Will be recalculated
};
const pagination = await calculatePagination(parsedBook.spine, parsedBook.resources, settings);
// Create book with pagination
const book: ReflowableBook = {
...parsedBook,
pagination,
position: restorePosition(
{ ...parsedBook, pagination },
savedProgress?.cfi,
savedProgress?.page
),
};
// Apply paginated styles
const container = document.getElementById("reader-content");
if (container) {
applyPaginatedStyles(container);
}
// Update state
const state = getState();
setState({
currentReader: book,
readerMetadata: {
title: book.metadata.title,
total_pages: pagination.totalPages,
format,
},
});
// Render first page
const { content } = await import("./formats/reflowable/navigation");
const { success, position, content: pageContent } = content.goToPage(book, book.position.currentPage);
if (success && container) {
const { renderPage } = await import("./formats/reflowable/content-renderer");
const pageData = pagination.pageMap.get(position.currentPage - 1) || null;
renderPage(container, pageContent, pageData);
// Update UI
updatePageDisplay(container, position.currentPage, pagination.totalPages);
updateProgressBar(container, Math.round(position.progress * 100));
}
}
// Line 95+: NEW - Handle viewport resize
export function handleResize(): void {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return;
const book = state.currentReader as ReflowableBook;
// New settings
const newSettings: PaginationSettings = {
fontSize: 16, // Could get from settings manager
lineHeight: 1.6,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight - 120,
wordsPerPage: 250,
};
// Check if recalculation needed
if (shouldRecalculate(book.pagination, newSettings)) {
// Save current CFI
const currentCFI = book.position.cfi;
// Recalculate pagination
calculatePagination(book.spine, book.resources, newSettings).then((newPagination) => {
const updatedBook = {
...book,
pagination: newPagination,
position: restorePosition({ ...book, pagination: newPagination }, currentCFI),
};
setState({ currentReader: updatedBook });
// Re-render current page
const { content } = require("./formats/reflowable/navigation");
const { success, position, content: pageContent } = content.goToPage(updatedBook, updatedBook.position.currentPage);
if (success) {
const container = document.getElementById("reader-content");
if (container) {
const { renderPage } = require("./formats/reflowable/content-renderer");
const pageData = newPagination.pageMap.get(position.currentPage - 1) || null;
renderPage(container, pageContent, pageData);
}
}
});
}
}
// Line 135+: Add resize listener
window.addEventListener("resize", debounce(handleResize, 300));
function debounce(func: Function, wait: number): Function {
let timeout: any;
return function(...args: any[]) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
```
---
### 10. MOVE `web/src/reader/features/progress-indicator.ts`
**MOVE TO** `web/src/reader/ui/progress-indicator.ts`
No changes needed - just move the file.
---
### 11. DELETE obsolete files and directories
After migration, delete these old files and directories:
**Individual files to delete:**
```
DELETE:
- web/src/reader/ebook/page-calculator.ts → Replaced by formats/reflowable/page-calculator.ts
- web/src/reader/ebook/view-modes.ts → Replaced by formats/reflowable/content-renderer.ts
- web/src/reader/ebook/page-splitter.ts → Merged into formats/reflowable/page-calculator.ts
- web/src/reader/ebook/cfi-navigator.ts → Merged into formats/reflowable/page-calculator.ts
- web/src/reader/ebook/html-renderer.ts → Replaced by formats/reflowable/content-renderer.ts
- web/src/reader/ebook/epub-parsers.ts → Keep, but verify imports work
```
**Entire directories to delete (after moving contents):**
```
DELETE DIRECTORIES:
- web/src/reader/ebook/ → All functionality moved to formats/reflowable/
- web/src/reader/pdf/ → Moved to formats/pdf/
- web/src/reader/comic/ → Moved to formats/comic/
- web/src/reader/manga/ → Moved to formats/manga/
- web/src/reader/features/ → Moved to ui/ (keep what's needed)
```
**Why these deletions:**
- `ebook/` was misleading - it handled multiple reflowable formats, not just EPUB
- Format-specific code mixed with shared code - now separated
- Old pagination logic was scroll-based - new is page-based
- Old CFI handling was incomplete - new version integrated into pagination
---
## Migration Steps
### Step 1: Create new directory structure
```bash
mkdir -p web/src/reader/formats/reflowable
mkdir -p web/src/reader/formats/pdf
mkdir -p web/src/reader/formats/comic
mkdir -p web/src/reader/formats/manga
mkdir -p web/src/reader/ui
```
### Step 2: Move existing format-specific code
```bash
# Move PDF files
mv web/src/reader/pdf/* web/src/reader/formats/pdf/
# Move comic files
mv web/src/reader/comic/* web/src/reader/formats/comic/
# Move manga files
mv web/src/reader/manga/* web/src/reader/formats/manga/
# Move UI files
mv web/src/reader/features/progress-indicator.ts web/src/reader/ui/
```
### Step 3: Create new reflowable files
Copy the code from sections 1-6 above into new files:
- `types.ts`
- `page-calculator.ts`
- `navigation.ts`
- `progress-tracker.ts`
- `content-renderer.ts`
- `parser.ts`
### Step 4: Update existing files
Apply changes from sections 7-9 to:
- `reader-navigation.ts`
- `reader-shell.ts`
### Step 5: Update imports across codebase
```bash
# Update import statements in files that reference moved modules
find web/src/reader -name "*.ts" -exec sed -i 's|from "../ebook/|from "../formats/reflowable/|g' {} \;
find web/src/reader -name "*.ts" -exec sed -i 's|from "../pdf/|from "../formats/pdf/|g' {} \;
find web/src/reader -name "*.ts" -exec sed -i 's|from "../comic/|from "../formats/comic/|g' {} \;
find web/src/reader -name "*.ts" -exec sed -i 's|from "../features/|from "../ui/|g' {} \;
```
### Step 6: Delete obsolete files and directories
⚠️ **CRITICAL**: Verify all files have been moved/copied before deleting!
```bash
# First, verify the new directories exist and have content
echo "Checking new directories..."
ls -la web/src/reader/formats/reflowable/ # Should have 6 new .ts files
ls -la web/src/reader/formats/pdf/ # Should have moved PDF files
ls -la web/src/reader/formats/comic/ # Should have moved comic files
ls -la web/src/reader/formats/manga/ # Should have moved manga files
ls -la web/src/reader/ui/ # Should have progress-indicator.ts
# If any directory is empty, STOP and investigate before proceeding!
# Delete individual obsolete files (if they still exist)
rm -f web/src/reader/ebook/page-calculator.ts
rm -f web/src/reader/ebook/view-modes.ts
rm -f web/src/reader/ebook/page-splitter.ts
rm -f web/src/reader/ebook/cfi-navigator.ts
rm -f web/src/reader/ebook/html-renderer.ts
# Delete entire old directories (after moving contents)
rm -rf web/src/reader/ebook/
rm -rf web/src/reader/pdf/
rm -rf web/src/reader/comic/
rm -rf web/src/reader/manga/
rm -rf web/src/reader/features/
# Verification: List what remains in reader/
echo "Remaining reader structure:"
ls -la web/src/reader/
# Should show: core/, formats/, ui/, parsers/ (and nothing else)
```
### Step 7: Test
```bash
cd web
npm run build
npm run typecheck
npm run lint
```
---
## Testing Checklist
- [ ] EPUB loads and displays first page
- [ ] Next/previous page navigation works
- [ ] Page count displays correctly
- [ ] Progress bar updates
- [ ] CFI is saved to database on page change
- [ ] Position is restored on reload
- [ ] Pagination recalculates on window resize (±50px)
- [ ] Pagination recalculates on font size change
- [ ] PDF reader still works
- [ ] Comic reader still works
- [ ] Manga reader still works
---
## Key Improvements
1. **Modularization**: Format-specific code is now separated
2. **No OOP**: All functions, plain objects
3. **Pre-calculated pages**: No scrolling within pages
4. **Word count pagination**: More stable than viewport measurement
5. **CFI tracking**: Accurate progress sync
6. **Resize handling**: Automatic recalculation with position restoration
---
## Next Steps After Implementation
1.~~Refine `getPageContent`~~ - **COMPLETED**: HTML slicing implemented using DOMParser
2.~~Page rendering~~ - **COMPLETED**: Properly renders sliced content with overflow handling
3.~~CFI generation~~ - **COMPLETED**: Full EPUB CFI spec compliance with escaping and parsing
4. Add chapter boundary detection (start new chapters on new pages)
5. Add reading time estimates
6. Implement search within book
7. Add highlight/annotation support