Files
bookhoard/READER_IMPLEMENTATION_PLAN.md
T
john-okeefe 9dd217afb6 docs: Complete Reader Implementation Plan overhaul with PDF support and universal reader architecture
Major Architecture Changes:
- Added PDF support (Mozilla pdf.js) with text selection, highlights, search, bookmarks
- Universal reader with pluggable parser pipeline for all reflowable ebooks
- Common Intermediate Format (CIF) to standardize ebook parsing
- Server-side parsing for complex formats (MOBI, AZW3, DOCX, RTF)
- Client-side parsing for simple formats (EPUB, FB2, TXT, HTML)
- PDF-specific features: TOC navigation, bookmarks, dual-page view, mini-map

Procedural TypeScript:
- Refactored all code to follow PROJECT_GUIDELINES.md (no classes, no OOP)
- Functions and modules instead of classes
- Functional techniques where appropriate

New Components:
- Parser manager (router) to detect format and route to appropriate parser
- CIF types for universal ebook representation
- PDF reader with full feature set (text-layer, annotation, search, etc.)
- Server-side Go handlers for MOBI/AZW3/DOCX/RTF parsing

Database Schema:
- Added pdf_bookmarks table for custom PDF bookmarks

API Routes:
- Added PDF outline/TOC endpoint
- Added PDF thumbnail endpoint for mini-map

Theming:
- Added PDF-specific reading themes (5 options: light, sepia, dark, night, high-contrast)
- Hybrid approach maintained: chrome_theme for UI, reading_theme for content
2026-04-02 16:54:34 -04:00

7703 lines
218 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD013 -->
# 📖 Bookhoard Reader Implementation Plan
## Overview
Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types.
**Design Philosophy:**
- **Universal reader architecture**: One rendering engine with pluggable parser components
- **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure
- **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT)
- **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md)
- **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems
- **Progressive enhancement**: SSR-first with TypeScript enhancements
- **Privacy-first**: Per-user settings with localStorage fallback
- **Offline-capable**: PWA with offline dictionary
- **Full PDF support**: Mozilla pdf.js for text selection, highlights, search
- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, mini-map, copy support
## What's New in This Version
### **Major Architecture Change: Universal Reader + Parsers**
**Previous approach:** Separate readers for each format (EbookReader, ComicReader, etc.)
**New approach:** Single universal reader with parser pipeline
```
All Reflowable Ebooks → Parse to CIF → Universal Reader
├── EPUB → EPUBParser → CIF → Universal Reader
├── FB2 → FB2Parser → CIF → Universal Reader
├── TXT → TXTParser → CIF → Universal Reader
├── HTML → HTMLParser → CIF → Universal Reader
├── MOBI → Server Parser → CIF → Universal Reader
├── AZW3 → Server Parser → CIF → Universal Reader
├── DOCX → Server Parser → CIF → Universal Reader
└── RTF → Server Parser → CIF → Universal Reader
```
**Benefits:**
- One codebase for UI/UX (fix once, works for all formats)
- Easy to add new formats (just implement parser interface)
- Consistent user experience across all ebooks
- ~500 KB total dependency size (vs. 182 MB Calibre)
### **Procedural TypeScript (No OOP)**
All code follows PROJECT_GUIDELINES.md:
- ❌ No classes
- ❌ No `this` capture
- ❌ No inheritance
- ✅ Functions and modules
- ✅ Functional techniques where helpful
- ✅ Procedural/imperative style
**Example:**
```typescript
// ❌ OLD (OOP - not allowed)
class EPUBParser {
private zip: JSZip | null = null;
async parse(blob: Blob): Promise<CIF> { ... }
}
// ✅ NEW (Procedural - correct)
export async function parseEPUB(blob: Blob): Promise<CIF> { ... }
```
---
## 1. Architecture
### 1.1 Universal Reader with Pluggable Parsers
**Architectural Decision: Single Reader + Parser Pipeline**
Instead of separate readers for each format, we use **one universal reader** with **pluggable parsers** that convert all formats to a **Common Intermediate Format (CIF)**.
```
┌──────────────────────────────────────────────────────────┐
│ Universal Ebook Reader (Single) │
│ - HTML Renderer (shared) │
│ - Typography Engine (shared) │
│ - Progress Tracker (shared) │
│ - Annotation Manager (shared) │
│ - Navigation Controls (shared) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Common Intermediate Format (CIF) │
│ - Standardized HTML structure │
│ - Universal metadata schema │
│ - Unified navigation (TOC) │
│ - Consistent resource loading │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Parser Manager (Router) │
│ Detects format → Routes to appropriate parser │
└──────────────────────────────────────────────────────────┘
┌─────────┬─────────┬──────────┬──────────┐
│ EPUB │ FB2 │ TXT │ HTML │ ← Client-side
│ Parser │ Parser │ Parser │ Parser │ (TypeScript)
└─────────┴─────────┴──────────┴──────────┘
┌─────────┬─────────┬──────────┬──────────┐
│ MOBI │ AZW3 │ DOCX │ RTF │ ← Server-side
│ Parser │ Parser │ Parser │ Parser │ (Go backend)
└─────────┴─────────┴──────────┴──────────┘
PDF and Comics use dedicated readers (not CIF pipeline):
- PDFReader (pdf.js) - Fixed-layout documents
- ComicReader (canvas) - Image archives
- MangaReader (extends Comic) - RTL/vertical modes
```
**Why This Approach?**
1. **Code Reuse**: One reader implementation for all reflowable ebooks
2. **Consistency**: All formats have identical UI/UX
3. **Maintainability**: Fix bug once, applies to all formats
4. **Extensibility**: Add new format by implementing parser interface
5. **Performance**: Client-side for simple formats, server-side for complex
### 1.2 Component Structure
```
Reader Infrastructure (Shared)
├── reader-shell.ts - UI shell, chrome control, routing
├── progress-tracker.ts - Integration with reading_progress table
├── annotation-manager.ts - Integration with notes/highlights tables
├── websocket-sync.ts - Reuse existing sync system
├── settings-manager.ts - Per-user preferences (DB + localStorage)
├── bookmark-manager.ts - Integration with existing bookmarks
└── chapter-detector.ts - Chapter detection for all media types
Universal Ebook Reader (Reflowable Formats)
├── html-renderer.ts - Browser-native HTML rendering (shared)
├── typography-engine.ts - Font rendering, theme integration (shared)
├── cfi-navigator.ts - Universal position navigation
├── dictionary-popup.ts - Offline dictionary lookup
Parser Pipeline
├── parser-manager.ts - Routes format → appropriate parser
├── cif-types.ts - Common Intermediate Format types
├── Client-Side Parsers (TypeScript)
│ ├── epub-parser.ts - EPUB 2/3 parsing (ZIP + XML)
│ ├── fb2-parser.ts - FictionBook 2 parsing (XML)
│ ├── txt-parser.ts - Plain text wrapper
│ └── html-parser.ts - Standalone HTML files
└── Server-Side Parsers (Go backend)
├── mobi-parser.go - MOBI parsing
├── azw3-parser.go - AZW3/KF8 parsing
├── docx-parser.go - Word document parsing
└── rtf-parser.go - Rich Text Format parsing
PDF Reader (Fixed Layout)
├── pdfjs-wrapper.ts - Mozilla pdf.js integration
├── text-layer-renderer.ts - Text layer overlay for selection
├── annotation-layer.ts - Highlight/note rendering
├── pdf-navigation.ts - Page navigation, zoom, fit modes
├── pdf-search.ts - Full-text search within PDF
├── page-cache.ts - 5-page ahead cache
├── text-selection.ts - Text selection and highlight creation
├── pdf-outline.ts - TOC navigation
├── pdf-bookmarks.ts - Custom bookmarks
├── pdf-clipboard.ts - Copy to clipboard
├── pdf-links.ts - Internal link handling
├── pdf-dual-page.ts - Dual page spread view
├── pdf-minimap.ts - Mini-map navigation
├── pdf-rotation.ts - Rotated page support
└── pdf-page-sizes.ts - Variable page size handling
Comic Reader (Image Archives)
├── image-archive-parser.ts - CBZ/CBR parsing
├── canvas-renderer.ts - Canvas rendering with lazy loading
├── panel-detector.ts - Grid-based + ML + manual override
├── panel-navigator.ts - Panel zoom with smooth animations
└── page-cache.ts - 5-page ahead cache
Manga Reader (extends Comic)
├── rtl-navigator.ts - Right-to-left navigation
├── vertical-scroll-mode.ts - Webtoon-style vertical scroll
└── panel-detector.ts - Manga-aware panel detection
```
### 1.3 Theming Strategy (Hybrid Approach)
**Design Decision:**
Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices:
```
┌─────────────────────────────────────────────────────────┐
│ UI Chrome (Bars, Panels, Settings) │
│ ✅ All 11 Bookhoard themes available │
│ - tokyo-night, dracula, nord, etc. │
│ - Maintains consistency with rest of app │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Ebook Text Content │
│ ✅ 5 reading-optimized themes only │
│ - Light (standard) │
│ - Sepia (warm, easier on eyes) │
│ - Dark (reduced eye strain) │
│ - Night (reduced blue light for better sleep) │
│ - High Contrast (accessibility) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ PDF Documents │
│ ✅ 5 reading-optimized themes only │
│ - Light, Sepia, Dark, Night, High Contrast │
│ - PDF.js supports custom CSS for text layer │
│ - Maintains readability for long documents │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Comic/Manga Images │
│ ✅ All 11 Bookhoard themes available │
│ - Visual content works well with any theme │
│ - No eye fatigue concerns with images │
└─────────────────────────────────────────────────────────┘
```
**Why This Approach?**
1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization
2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes
3. **Accessibility**: Reading-optimized themes help users with visual impairments
4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions
5. **Flexibility**: Still have full theming for UI and visual content
**Popular Ebook Reader Comparison:**
| Reader | Reading Themes | Color Options? |
|-------------|----------------|----------------|
| Kindle | 4 | No |
| Kobo | 4 | No (green for night) |
| Apple Books | 5 | No |
| **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** |
**Implementation:**
- `chrome_theme`: Applied to reader shell, navigation bars, settings panels
- `reading_theme`: Applied to ebook text content only (5 options)
- Comics/manga: Use `chrome_theme` (all 11 themes work well)
### 1.4 Data Flow
```
User opens reader
Backend: GET /api/readers/:mediaItemId
Verify access, fetch metadata, progress, bookmarks
SSR render: templates/reader.templ with initial data
Frontend: Initialize appropriate reader (Ebook/PDF/Comic/Manga)
Load content (lazy load + cache)
User interacts (turn page, highlight, bookmark)
Real-time sync via WebSocket (reuse existing system)
```
---
## 2. Database Schema Changes
### 2.1 New Tables
```sql
-- Panel detection data
CREATE TABLE panel_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
panels JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, page_number)
);
CREATE INDEX idx_panel_data_media_item ON panel_data(media_item_id);
-- Reading speed tracking
CREATE TABLE reading_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
words_per_minute DECIMAL(6,2),
pages_per_minute DECIMAL(6,2),
pages_read INTEGER DEFAULT 0,
total_reading_minutes DECIMAL(8,2) DEFAULT 0,
last_read_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX idx_reading_speed_item ON reading_speed(media_item_id);
-- Dictionary cache (for offline use)
CREATE TABLE dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
word VARCHAR(100) NOT NULL UNIQUE,
definition TEXT NOT NULL,
part_of_speech VARCHAR(20),
example TEXT,
etymology TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
accessed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_dictionary_word ON dictionary_cache(word);
-- Reader settings (per-user preferences)
CREATE TABLE reader_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(50) NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, setting_key)
);
CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);
-- PDF bookmarks (custom user bookmarks)
CREATE TABLE pdf_bookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
position VARCHAR(100), -- 'pdf:page:45' for consistency
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, user_id, page_number)
);
CREATE INDEX idx_pdf_bookmarks_media ON pdf_bookmarks(media_item_id);
CREATE INDEX idx_pdf_bookmarks_user ON pdf_bookmarks(user_id);
```
### 2.2 Alter Existing Tables
```sql
-- Add chapter metadata to media_items
ALTER TABLE media_items
ADD COLUMN chapter_metadata JSONB;
-- Example structure:
-- {
-- "chapters": [
-- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20},
-- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25}
-- ]
-- }
-- Note: reading_progress table already exists with epubcfi, page, percentage fields
-- Note: notes and highlights tables already exist
-- Note: bookmarks table already exists
```
### 2.3 Schema.sql Implementation
**File:** `database/schema/schema.sql`
Add the above tables to the schema file. Follow existing patterns:
- Use `gen_random_uuid()` for UUID defaults
- Use `TIMESTAMPTZ DEFAULT NOW()` for timestamps
- Add appropriate indexes for foreign keys
- Use `ON DELETE CASCADE` for referential integrity
---
## 3. API Endpoints
### 3.1 Reader Routes
**File:** `internal/router/reader.go` (new file)
```go
package router
func registerReaderRoutes(cfg *Config) {
e := cfg.Echo
jwtMiddleware := createJWTMiddleware(cfg)
reader := e.Group("/readers", jwtMiddleware)
// Reader page (SSR)
reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader)
// Content serving (lazy-loaded pages)
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
// Chapter metadata
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
// Panel data (comics/manga)
reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) // Manual override
// PDF outline/TOC
reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline)
// PDF thumbnails (for mini-map)
reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail)
// Reading speed
reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed)
reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed)
// Dictionary lookup
reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord)
// Reader settings
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
}
// Bookmarks API (reuse existing media routes)
func registerBookmarkRoutes(cfg *Config) {
e := cfg.Echo
jwtMiddleware := createJWTMiddleware(cfg)
bookmarks := e.Group("/api/media-items/:mediaItemId/bookmarks", jwtMiddleware)
// CRUD operations for bookmarks
bookmarks.GET("", cfg.ReaderHandler.GetBookmarks)
bookmarks.POST("", cfg.ReaderHandler.CreateBookmark)
bookmarks.DELETE("/:bookmarkId", cfg.ReaderHandler.DeleteBookmark)
}
```
### 3.2 Handler Implementation
**File:** `internal/handlers/reader.go` (new file)
Follow existing patterns from `media.go` and `auth.go`:
- Use `database.Queries` for all DB operations
- Return JSON responses with consistent structure
- Handle errors properly (404, 403, 500)
- Support content negotiation (JSON for API, HTML for SSR)
**Key handler signatures:**
```go
type ReaderHandler struct {
db *database.Queries
libraryService *services.LibraryService
worker *services.Worker
}
func (h *ReaderHandler) ShowReader(c echo.Context) error
func (h *ReaderHandler) GetPage(c echo.Context) error
func (h *ReaderHandler) GetChapters(c echo.Context) error
func (h *ReaderHandler) GetPanels(c echo.Context) error
func (h *ReaderHandler) UpdatePanels(c echo.Context) error
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error
func (h *ReaderHandler) LookupWord(c echo.Context) error
func (h *ReaderHandler) GetSettings(c echo.Context) error
func (h *ReaderHandler) UpdateSettings(c echo.Context) error
```
### 3.3 Service Layer
**File:** `internal/services/reader_service.go` (new file)
All business logic goes here, not in handlers:
```go
type ReaderService struct {
db *database.Queries
worker *services.Worker
}
// Chapter detection for all media types
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error)
// Panel detection (grid-based, ML, manual)
func (s *ReaderService) DetectPanels(ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string) ([]Panel, error)
// Reading speed calculation
func (s *ReaderService) CalculateReadingSpeed(ctx context.Context, userID, mediaItemID uuid.UUID, pagesRead int, minutes float64) error
// Dictionary lookup (with cache)
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error)
// Settings management (DB + localStorage sync)
func (s *ReaderService) GetSettings(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error)
func (s *ReaderService) UpdateSettings(ctx context.Context, userID uuid.UUID, settings map[string]interface{}) error
```
---
## 4. Frontend Implementation
### 4.1 File Structure
```
web/src/reader/
├── reader.ts - Main reader entry point
├── reader-shell.ts - UI shell, chrome control
├── progress-indicator.ts - KOReader-style switchable progress
├── settings-manager.ts - Settings (DB + localStorage)
├── slide-in-panel.ts - Shared slide-in panel (TOC + Settings)
├── annotation-manager.ts - Highlights, notes, bookmarks
├── websocket-sync.ts - Reuse existing websocket.ts
├── dictionary-popup.ts - Offline dictionary lookup
├── ebook/
│ ├── epub-parser.ts - EPUB parsing (ZIP + XML)
│ ├── html-renderer.ts - Browser-native rendering
│ ├── cfi-navigator.ts - EPUB CFI navigation
│ ├── typography-engine.ts - Font rendering, themes
│ └── chapter-detector.ts - Chapter detection
├── comic/
│ ├── image-parser.ts - CBZ/CBR/PDF parsing
│ ├── canvas-renderer.ts - Canvas rendering
│ ├── panel-detector.ts - Grid + ML + manual
│ ├── panel-navigator.ts - Panel zoom animations
│ └── page-cache.ts - 5-page ahead cache
└── manga/
├── rtl-navigator.ts - Right-to-left navigation
└── vertical-scroll.ts - Webtoon-style scroll
```
### 4.2 TypeScript Types
**File:** `web/src/types/reader.d.ts` (new file)
```typescript
// ============================================================
// Common Intermediate Format (CIF) Types
// Universal format for all reflowable ebooks after parsing
// ============================================================
interface EbookCIF {
// Universal metadata (all formats)
metadata: {
title: string;
author: string;
language: string;
publisher?: string;
isbn?: string;
coverImage?: Blob;
};
// Unified navigation structure
toc: TOCNode[];
// Content spine (reading order)
spine: SpineItem[];
// Resources (CSS, fonts, images)
resources: Map<string, Blob>;
// Progress tracking (minimal - backend handles detailed tracking)
locations: {
totalCharacters: number;
estimatedPages: number;
};
}
interface SpineItem {
id: string;
type: 'html' | 'image';
content: string;
properties?: string[];
// Minimal position info for UI
index: number;
}
interface TOCNode {
id: string;
title: string;
href: string;
children: TOCNode[];
}
// ============================================================
// Parser Types (Procedural, not OOP)
// ============================================================
type ParserFormat = 'epub' | 'fb2' | 'txt' | 'html' | 'mobi' | 'azw3' | 'docx' | 'rtf';
interface ParserCapabilities {
canParse(mimeType: string, extension: string): boolean;
parse(file: Blob): Promise<EbookCIF>;
extractMetadata(file: Blob): Promise<Partial<EbookCIF['metadata']>>;
}
// ============================================================
// Reader Metadata (from API)
// ============================================================
interface ReaderMetadata {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
library_type: 'ebook' | 'comic' | 'manga' | 'pdf';
mime_type: string;
file_path: string;
chapter_metadata?: ChapterMetadata;
total_pages?: number;
}
// ============================================================
// Other Shared Types
// ============================================================
interface ChapterMetadata {
chapters: Chapter[];
}
interface Chapter {
id: string;
title: string;
start_page: number;
page_count: number;
}
interface PanelData {
media_item_id: string;
page_number: number;
detection_method: 'grid' | 'ml' | 'manual';
panels: Panel[];
updated_at: string;
}
interface Panel {
id: string;
x: number;
y: number;
width: number;
height: number;
reading_order: number;
}
interface ReadingSpeed {
words_per_minute: number;
pages_per_minute: number;
pages_read: number;
total_reading_minutes: number;
last_read_at: string;
}
interface DictionaryEntry {
word: string;
definition: string;
part_of_speech?: string;
example?: string;
etymology?: string;
}
interface ReaderSettings {
chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll';
progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
chrome_theme: string;
reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
font_size: number;
line_height: number;
margin_width: number;
tap_zone_size: number;
auto_scroll: boolean;
panel_zoom_enabled: boolean;
double_page_spread: boolean;
reading_direction: 'ltr' | 'rtl' | 'vertical';
pdf_fit_mode: 'fit-width' | 'fit-page' | 'fit-height' | 'none';
pdf_zoom_level: number;
pdf_text_layer_enabled: boolean;
pdf_dual_page_mode: 'auto' | 'single' | 'dual';
pdf_dual_page_threshold: number;
pdf_minimap_enabled: boolean;
pdf_outline_visible: boolean;
pdf_bookmarks_visible: boolean;
hardware_acceleration: boolean;
}
interface ProgressDisplay {
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
current: number;
total: number;
label?: string;
time_left?: string;
}
```
### 4.3 Universal Reader Shell (Procedural)
**File:** `web/src/reader/reader-shell.ts`
```typescript
// Universal Reader Shell - Routes to appropriate reader
// Procedural style: Functions, not classes
import { Alpine } from "../alpine";
import { getReaderMetadata, updateReadingProgress } from "./api";
import { SettingsManager } from "./settings-manager";
import { ProgressIndicator } from "./progress-indicator";
import { parseEbook, requiresServerParsing } from './parser-manager';
import { initializePDFReader } from './pdf/pdfjs-wrapper';
import { initializeComicReader } from './comic/image-parser';
// ============================================================
// Reader State
// ============================================================
let currentReader: UniversalReader | PDFReader | ComicReader | MangaReader | null = null;
let readerMetadata: ReaderMetadata | null = null;
interface UniversalReader {
type: 'ebook';
cif: EbookCIF;
currentSpineIndex: number;
}
interface PDFReader {
type: 'pdf';
doc: any;
currentPage: number;
}
interface ComicReader {
type: 'comic';
images: Blob[];
currentPage: number;
}
interface MangaReader {
type: 'manga';
images: Blob[];
currentPage: number;
readingDirection: 'rtl' | 'vertical';
}
// ============================================================
// Initialization
// ============================================================
async function initializeReader(): Promise<void> {
const mediaItemId = document.body.dataset.mediaItemId;
if (!mediaItemId) return;
// Fetch metadata
readerMetadata = await getReaderMetadata(mediaItemId);
// Initialize appropriate reader based on type
switch (readerMetadata.library_type) {
case 'ebook':
currentReader = await initializeEbookReader(readerMetadata);
break;
case 'pdf':
currentReader = await initializePDFReader(readerMetadata);
break;
case 'comic':
currentReader = await initializeComicReader(readerMetadata);
break;
case 'manga':
currentReader = await initializeMangaReader(readerMetadata);
break;
}
if (currentReader) {
setupReaderUI();
}
}
async function initializeEbookReader(metadata: ReaderMetadata): Promise<UniversalReader> {
// Check if server-side parsing is needed
const needsServer = requiresServerParsing(metadata.mime_type, getFileExtension(metadata.file_path));
let ebookFile: Blob;
if (needsServer) {
// Fetch parsed CIF from server
const response = await fetch(`/api/readers/${metadata.media_item_id}/parse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mime_type: metadata.mime_type,
file_path: metadata.file_path,
}),
});
if (!response.ok) {
throw new Error(`Server parsing failed: ${response.statusText}`);
}
ebookFile = await response.blob();
} else {
// Fetch original file for client-side parsing
const response = await fetch(metadata.file_path);
ebookFile = await response.blob();
}
// Parse ebook to CIF
const cif = await parseEbook(ebookFile, metadata.mime_type, getFileExtension(metadata.file_path));
return {
type: 'ebook',
cif,
currentSpineIndex: 0,
};
}
// ============================================================
// UI Setup
// ============================================================
function setupReaderUI(): void {
if (!currentReader || !readerMetadata) return;
// Setup chrome
setupChromeBehavior();
// Setup progress indicator
setupProgressIndicator();
// Setup annotations
setupAnnotations();
// Setup keyboard navigation
setupKeyboardNavigation();
}
function setupChromeBehavior(): void {
const chrome = document.getElementById('reader-chrome');
if (!chrome) return;
// Auto-hide on scroll
let hideTimeout: NodeJS.Timeout;
window.addEventListener('scroll', () => {
chrome.classList.add('visible');
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
chrome.classList.remove('visible');
}, 2000);
});
// Toggle on tap (for touch devices)
chrome.addEventListener('click', () => {
chrome.classList.toggle('visible');
});
}
function setupProgressIndicator(): void {
// Update progress based on reader type
if (!currentReader) return;
if (currentReader.type === 'ebook') {
updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex);
} else if (currentReader.type === 'pdf') {
updatePDFProgress(currentReader.currentPage, readerMetadata.total_pages || 0);
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
updateComicProgress(currentReader.currentPage, currentReader.images.length);
}
}
function setupAnnotations(): void {
// Load existing highlights and notes
// Implementation depends on annotation system
}
function setupKeyboardNavigation(): void {
document.addEventListener('keydown', (e) => {
if (!currentReader) return;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
nextPage();
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
previousPage();
break;
}
});
}
// ============================================================
// Navigation Functions
// ============================================================
function nextPage(): void {
if (!currentReader) return;
if (currentReader.type === 'ebook') {
nextSpineItem();
} else if (currentReader.type === 'pdf') {
nextPDFPage();
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
nextComicPage();
}
}
function previousPage(): void {
if (!currentReader) return;
if (currentReader.type === 'ebook') {
previousSpineItem();
} else if (currentReader.type === 'pdf') {
previousPDFPage();
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
previousComicPage();
}
}
function nextSpineItem(): void {
if (currentReader?.type !== 'ebook') return;
if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) {
currentReader.currentSpineIndex++;
renderCurrentSpineItem();
}
}
function previousSpineItem(): void {
if (currentReader?.type !== 'ebook') return;
if (currentReader.currentSpineIndex > 0) {
currentReader.currentSpineIndex--;
renderCurrentSpineItem();
}
}
function renderCurrentSpineItem(): void {
if (currentReader?.type !== 'ebook') return;
const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex];
const container = document.getElementById('reader-content');
if (!container) return;
// Render spine item content
container.innerHTML = spineItem.content;
// Apply theme and typography
applyReaderTheme();
applyTypography();
// Update progress
updateProgress();
}
// ============================================================
// Progress Tracking
// ============================================================
function updateProgress(): void {
if (!currentReader || !readerMetadata) return;
let percentage = 0;
let currentPosition = '';
if (currentReader.type === 'ebook') {
const totalSpine = currentReader.cif.spine.length;
percentage = (currentReader.currentSpineIndex + 1) / totalSpine;
currentPosition = `spine:${currentReader.currentSpineIndex}`;
} else if (currentReader.type === 'pdf') {
const totalPages = readerMetadata.total_pages || 1;
percentage = currentReader.currentPage / totalPages;
currentPosition = `page:${currentReader.currentPage}`;
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
const totalPages = currentReader.images.length;
percentage = currentReader.currentPage / totalPages;
currentPosition = `page:${currentReader.currentPage}`;
}
// Send to backend
updateReadingProgress(readerMetadata.media_item_id, {
percentage,
current_page: currentReader.type === 'ebook' ? currentReader.currentSpineIndex : currentReader.currentPage,
position: currentPosition,
});
}
// ============================================================
// Alpine.js Integration
// ============================================================
Alpine.data('readerShell', () => ({
init() {
initializeReader();
},
nextPage,
previousPage,
get currentPage() {
if (!currentReader) return 0;
if (currentReader.type === 'ebook') {
return currentReader.currentSpineIndex + 1;
} else {
return currentReader.currentPage;
}
},
get totalPages() {
if (!currentReader || !readerMetadata) return 0;
if (currentReader.type === 'ebook') {
return currentReader.cif.spine.length;
} else if (currentReader.type === 'pdf') {
return readerMetadata.total_pages || 0;
} else {
return currentReader.images.length;
}
},
}));
// ============================================================
// Utility Functions
// ============================================================
function getFileExtension(filepath: string): string {
const match = filepath.match(/\.([^.]+)$/);
return match ? `.${match[1]}` : '';
}
function applyReaderTheme(): void {
// Apply reading theme from settings
const settings = getReaderSettings();
const container = document.getElementById('reader-content');
if (!container) return;
container.className = `ebook-content theme-${settings.reading_theme}`;
}
function applyTypography(): void {
const settings = getReaderSettings();
const container = document.getElementById('reader-content');
if (!container) return;
container.style.fontSize = `${settings.font_size}px`;
container.style.lineHeight = settings.line_height.toString();
container.style.fontFamily = getFontStack(settings.reading_font);
}
function getFontStack(font: string): string {
const stacks: 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 stacks[font] || stacks['literata'];
}
function getReaderSettings(): ReaderSettings {
// Load from settings manager
return {} as ReaderSettings; // Simplified
}
```
---
### 4.4 Server-Side Parsers (Go Backend)
**File:** `internal/handlers/reader.go` (new file)
```go
package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/labstack/echo/v5"
"github.com/google/uuid"
)
type ReaderHandler struct {
db *database.Queries
libraryService *services.LibraryService
}
func NewReaderHandler(db *database.Queries, libraryService *services.LibraryService) *ReaderHandler {
return &ReaderHandler{
db: db,
libraryService: libraryService,
}
}
// ParseEbook parses complex ebook formats on the server
func (h *ReaderHandler) ParseEbook(c echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
}
// Fetch media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), parsedUUID)
if err != nil {
return c.JSON(404, map[string]string{"error": "Media item not found"})
}
// Route to appropriate parser based on format
var cif interface{}
switch mediaItem.MimeType.String {
case "application/x-mobipocket-ebook":
cif, err = h.parseMOBI(c.Request().Context(), mediaItem.FilePath)
case "application/vnd.amazon.mobi8-ebook":
cif, err = h.parseAZW3(c.Request().Context(), mediaItem.FilePath)
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
cif, err = h.parseDOCX(c.Request().Context(), mediaItem.FilePath)
case "application/rtf":
cif, err = h.parseRTF(c.Request().Context(), mediaItem.FilePath)
default:
return c.JSON(400, map[string]string{"error": "Unsupported format for server-side parsing"})
}
if err != nil {
return c.JSON(500, map[string]string{"error": "Parsing failed: " + err.Error()})
}
return c.JSON(200, cif)
}
// Placeholder parser implementations
func (h *ReaderHandler) parseMOBI(ctx context.Context, filePath string) (interface{}, error) {
// TODO: Implement MOBI parsing
return nil, echo.NewHTTPError(501, "MOBI parser not implemented yet")
}
func (h *ReaderHandler) parseAZW3(ctx context.Context, filePath string) (interface{}, error) {
// TODO: Implement AZW3 parsing
return nil, echo.NewHTTPError(501, "AZW3 parser not implemented yet")
}
func (h *ReaderHandler) parseDOCX(ctx context.Context, filePath string) (interface{}, error) {
// TODO: Implement DOCX parsing
return nil, echo.NewHTTPError(501, "DOCX parser not implemented yet")
}
func (h *ReaderHandler) parseRTF(ctx context.Context, filePath string) (interface{}, error) {
// TODO: Implement RTF parsing
return nil, echo.NewHTTPError(501, "RTF parser not implemented yet")
}
```
**File:** `web/src/reader/reader-shell.ts`
```typescript
// Shared reader infrastructure
// Implements chrome control, routing, settings sync
import { Alpine } from "../alpine";
import { getReaderMetadata, updateReadingProgress } from "./api";
import { SettingsManager } from "./settings-manager";
import { ProgressIndicator } from "./progress-indicator";
let currentReader: EbookReader | PDFReader | ComicReader | MangaReader | null = null;
function initializeReader(): void {
const mediaItemId = document.body.dataset.mediaItemId;
if (!mediaItemId) return;
// Fetch metadata
getReaderMetadata(mediaItemId).then((metadata) => {
// Initialize appropriate reader based on type
switch (metadata.library_type) {
case 'ebook':
currentReader = new EbookReader(metadata);
break;
case 'pdf':
currentReader = new PDFReader(metadata);
break;
case 'comic':
currentReader = new ComicReader(metadata);
break;
case 'manga':
currentReader = new MangaReader(metadata);
break;
}
currentReader?.initialize();
});
}
// Chrome control
function toggleChrome(): void {
const chrome = document.getElementById('reader-chrome');
chrome?.classList.toggle('hidden');
}
function setChromeBehavior(behavior: ReaderSettings['chrome_behavior']): void {
// Auto-hide, always-visible, or hide-on-scroll
}
// Export for Alpine integration
Alpine.data('readerShell', () => ({
init() {
initializeReader();
}
}));
```
### 4.5 Progress Indicator (KOReader-style)
**File:** `web/src/reader/progress-indicator.ts`
```typescript
// KOReader-style switchable progress indicator
import { Alpine } from "../alpine";
import { getReadingSpeed } from "./api";
interface ProgressDisplay {
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
text: string;
}
function calculateProgress(
currentPage: number,
totalPages: number,
currentChapterPage: number,
chapterPages: number,
readingSpeed?: ReadingSpeed
): ProgressDisplay {
const mode = getCurrentProgressMode(); // From settings
switch (mode) {
case 'pages':
return {
mode: 'pages',
text: `${currentPage}/${totalPages}`
};
case 'chapter':
return {
mode: 'chapter',
text: `${currentChapterPage}/${chapterPages}`
};
case 'percentage':
const percentage = Math.round((currentPage / totalPages) * 100);
return {
mode: 'percentage',
text: `${percentage}%`
};
case 'time-left':
if (!readingSpeed) {
return { mode: 'time-left', text: '--:--' };
}
const pagesLeft = totalPages - currentPage;
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return {
mode: 'time-left',
text: `${hours}h ${mins}m`
};
}
}
function cycleProgressMode(): void {
const modes: Array<'pages' | 'chapter' | 'percentage' | 'time-left'> =
['pages', 'chapter', 'percentage', 'time-left'];
const currentMode = getCurrentProgressMode();
const currentIndex = modes.indexOf(currentMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setProgressMode(nextMode);
}
```
### 4.6 Settings Manager (DB + localStorage)
**File:** `web/src/reader/settings-manager.ts`
```typescript
// Per-user settings with localStorage fallback
import { apiGet, apiPut } from "../api";
import { getToken, setItem, getItem } from "../storage";
const SETTINGS_KEY = 'reader_settings';
const LOCALSTORAGE_KEY = 'reader_settings_local';
interface SettingsManager {
load(): Promise<ReaderSettings>;
save(settings: Partial<ReaderSettings>): Promise<void>;
sync(): Promise<void>; // Sync localStorage → DB
get(key: keyof ReaderSettings): any;
set(key: keyof ReaderSettings, value: any): Promise<void>;
}
async function loadSettings(): Promise<ReaderSettings> {
const token = getToken();
if (!token) {
// Fallback to localStorage
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
try {
const response = await apiGet('/readers/settings');
const settings = await response.json();
// Cache in localStorage
setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
return settings;
} catch (error) {
// Fallback to localStorage on error
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
}
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
const token = getToken();
if (!token) {
// Save to localStorage only
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut('/readers/settings', settings);
// Update localStorage cache
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
// Fallback to localStorage
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: 'auto-hide',
progress_mode: 'pages',
chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available
reading_theme: 'dark', // Ebook text: 5 reading-optimized themes
reading_font: 'literata', // Default reading font (designed for ebooks)
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
font_size: 16,
line_height: 1.6,
margin_width: 20,
double_page_spread: false,
reading_direction: 'ltr',
hardware_acceleration: true
};
}
```
---
## 5. Ebook Reader Implementation
### 5.1 Parser Manager (Procedural)
**File:** `web/src/reader/parser-manager.ts`
```typescript
// Parser Manager - Routes files to appropriate parsers
// Procedural style: Functions, not classes
import JSZip from 'jszip';
// ============================================================
// Parser Registry
// ============================================================
const PARSER_REGISTRY: ParserEntry[] = [
{ format: 'epub', mimeType: 'application/epub+zip', extensions: ['.epub'], side: 'client' },
{ format: 'fb2', mimeType: 'application/fb2', extensions: ['.fb2', '.fb2.zip'], side: 'client' },
{ format: 'txt', mimeType: 'text/plain', extensions: ['.txt'], side: 'client' },
{ format: 'html', mimeType: 'text/html', extensions: ['.html', '.htm'], side: 'client' },
{ format: 'mobi', mimeType: 'application/x-mobipocket-ebook', extensions: ['.mobi', '.azw'], side: 'server' },
{ format: 'azw3', mimeType: 'application/vnd.amazon.mobi8-ebook', extensions: ['.azw3'], side: 'server' },
{ format: 'docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'], side: 'server' },
{ format: 'rtf', mimeType: 'application/rtf', extensions: ['.rtf'], side: 'server' },
];
interface ParserEntry {
format: string;
mimeType: string;
extensions: string[];
side: 'client' | 'server';
}
// ============================================================
// Parser Detection
// ============================================================
export function detectParserFormat(mimeType: string, extension: string): ParserEntry | null {
return PARSER_REGISTRY.find(entry =>
entry.mimeType === mimeType ||
entry.extensions.includes(extension.toLowerCase())
) || null;
}
export function requiresServerParsing(mimeType: string, extension: string): boolean {
const entry = detectParserFormat(mimeType, extension);
return entry?.side === 'server' || false;
}
// ============================================================
// Main Parse Function (Router)
// ============================================================
export async function parseEbook(file: Blob, mimeType: string, extension: string): Promise<EbookCIF> {
const entry = detectParserFormat(mimeType, extension);
if (!entry) {
throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`);
}
if (entry.side === 'server') {
return parseEbookOnServer(file, entry.format);
} else {
return parseEbookOnClient(file, entry.format);
}
}
// ============================================================
// Client-Side Parsing
// ============================================================
async function parseEbookOnClient(file: Blob, format: string): Promise<EbookCIF> {
switch (format) {
case 'epub':
return parseEPUB(file);
case 'fb2':
return parseFB2(file);
case 'txt':
return parseTXT(file);
case 'html':
return parseHTML(file);
default:
throw new Error(`Client-side parser not implemented for: ${format}`);
}
}
// ============================================================
// Server-Side Parsing (API Call)
// ============================================================
async function parseEbookOnServer(file: Blob, format: string): Promise<EbookCIF> {
const formData = new FormData();
formData.append('file', file);
formData.append('format', format);
const response = await fetch('/api/readers/parse', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Server parsing failed: ${response.statusText}`);
}
return await response.json();
}
```
---
### 5.2 EPUB Parser (Refactored - Procedural)
**File:** `web/src/reader/parsers/epub-parser.ts`
```typescript
// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format
// Procedural style: Functions, not classes
import JSZip from 'jszip';
// ============================================================
// Main Parse Function
// ============================================================
export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> {
const zip = await JSZip.loadAsync(epubBlob);
// Parse container.xml to find OPF file
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
throw new Error('Invalid EPUB: no OPF file found');
}
// Parse OPF file
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
// Extract all components
const metadata = extractMetadata(packageDoc);
const spine = parseSpine(packageDoc);
const toc = await parseTOC(zip, packageDoc, opfPath);
const resources = await loadResources(zip);
const coverImage = await extractCover(zip, packageDoc);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = await calculateTotalCharacters(spine, resources);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
async function getZipFileContent(zip: JSZip, path: string): Promise<string> {
const file = zip.file(path);
if (!file) {
throw new Error(`File not found: ${path}`);
}
return await file.async('text');
}
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
}
function extractOPFPath(containerXml: string): string | null {
const containerDoc = parseXML(containerXml);
return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null;
}
function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] {
const metadata = packageDoc.querySelector('metadata');
if (!metadata) {
throw new Error('No metadata found in OPF');
}
return {
title: metadata.querySelector('title')?.textContent || '',
author: metadata.querySelector('creator')?.textContent || '',
language: metadata.querySelector('language')?.textContent || 'en',
publisher: metadata.querySelector('publisher')?.textContent || undefined,
isbn: metadata.querySelector('identifier')?.textContent || undefined,
};
}
function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] {
const spine = packageDoc.querySelector('spine');
const manifest = packageDoc.querySelector('manifest');
if (!spine || !manifest) {
throw new Error('No spine or manifest found in OPF');
}
const spineItems = spine.querySelectorAll('itemref');
const result: EbookCIF['spine'] = [];
spineItems.forEach((itemref) => {
const idref = itemref.getAttribute('idref');
if (!idref) return;
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
if (!manifestItem) return;
const href = manifestItem.getAttribute('href');
if (!href) return;
result.push({
id: idref,
type: 'html',
content: href,
properties: itemref.getAttribute('properties') || undefined,
});
});
return result;
}
async function parseTOC(zip: JSZip, packageDoc: XMLDocument, opfPath: string): Promise<EbookCIF['toc']> {
// Try EPUB 3.0 navigation document first
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
if (navItem) {
const navHref = navItem.getAttribute('href');
if (navHref) {
const navPath = resolvePath(opfPath, navHref);
return parseNavTOC(zip, navPath);
}
}
// Fallback to EPUB 2.0 NCX
const ncxId = spine?.getAttribute('toc');
if (ncxId) {
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) {
const ncxHref = ncxItem.getAttribute('href');
if (ncxHref) {
const ncxPath = resolvePath(opfPath, ncxHref);
return parseNCXTOC(zip, ncxPath);
}
}
}
return [];
}
async function parseNavTOC(zip: JSZip, navPath: string): Promise<EbookCIF['toc']> {
const navXml = await getZipFileContent(zip, navPath);
const navDoc = parseXML(navXml);
const nav = navDoc.querySelector('nav');
if (!nav) return [];
const ol = nav.querySelector('ol');
if (!ol) return [];
const items = ol.querySelectorAll(':scope > li');
const result: EbookCIF['toc'] = [];
for (const li of items) {
const link = li.querySelector('a');
if (link) {
result.push({
id: link.getAttribute('href') || '',
title: link.textContent || '',
href: link.getAttribute('href') || '',
children: [],
});
}
}
return result;
}
async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise<EbookCIF['toc']> {
const ncxXml = await getZipFileContent(zip, ncxPath);
const ncxDoc = parseXML(ncxXml);
const navMap = ncxDoc.querySelector('navMap');
if (!navMap) return [];
return parseNCXNode(navMap);
}
function parseNCXNode(node: Element): EbookCIF['toc'] {
const navPoints = node.querySelectorAll(':scope > navPoint');
const result: EbookCIF['toc'] = [];
navPoints.forEach((navPoint) => {
const label = navPoint.querySelector('navLabel text')?.textContent || '';
const content = navPoint.querySelector('content');
const href = content?.getAttribute('src') || '';
result.push({
id: href,
title: label,
href,
children: parseNCXNode(navPoint),
});
});
return result;
}
async function loadResources(zip: JSZip): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const files = Object.keys(zip.files);
for (const path of files) {
const file = zip.file(path);
if (file && !file.dir) {
const blob = await file.async('blob');
resources.set(path, blob);
}
}
return resources;
}
async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise<Blob | undefined> {
// Try cover-id metadata
const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content');
if (coverId) {
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async('blob');
}
}
}
}
// Fallback: look for cover image in manifest
const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]');
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async('blob');
}
}
}
return undefined;
}
function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseDir + relativePath;
}
async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map<string, Blob>): Promise<number> {
let total = 0;
for (const item of spine) {
if (item.type === 'html') {
const content = resources.get(item.content);
if (content) {
const text = await content.text();
total += text.length;
}
}
}
return total;
}
function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseDir + relativePath;
}
}
}
return total;
}
function generatePageBreaks(totalCharacters: number): number[] {
const breaks: number[] = [];
const charsPerPage = 1000; // Rough estimate
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
breaks.push(i);
}
return breaks;
}
// ============================================================
// Metadata Quick Extract (for library view)
// ============================================================
export async function extractEPUBMetadata(epubBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
const zip = await JSZip.loadAsync(epubBlob);
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
return {};
}
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
return extractMetadata(packageDoc);
}
```
---
### 5.3 FictionBook 2 (FB2) Parser
**File:** `web/src/reader/parsers/fb2-parser.ts`
```typescript
// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format
// FB2 is XML-based, similar to EPUB structure
// Procedural style: Functions, not classes
import JSZip from 'jszip';
// ============================================================
// Main Parse Function
// ============================================================
export async function parseFB2(fb2Blob: Blob): Promise<EbookCIF> {
// FB2 can be plain XML or zipped (.fb2.zip)
let xmlContent: string;
if (fb2Blob.type === 'application/zip' || fb2Blob.type === 'application/x-zip-compressed') {
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
// Find the first .fb2 file in the zip
const fb2File = files.find(f => f.endsWith('.fb2'));
if (!fb2File) {
throw new Error('No .fb2 file found in archive');
}
xmlContent = await zip.file(fb2File)!.async('text');
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
const metadata = extractFB2Metadata(xmlDoc);
const toc = parseFB2TOC(xmlDoc);
const spine = createFB2Spine(xmlDoc);
const resources = await extractFB2Resources(xmlDoc, fb2Blob);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = calculateFB2Characters(xmlDoc);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
}
function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF['metadata'] {
const titleInfo = xmlDoc.querySelector('title-info');
const documentInfo = xmlDoc.querySelector('document-info');
if (!titleInfo) {
throw new Error('Invalid FB2: no title-info found');
}
return {
title: titleInfo.querySelector('book-title')?.textContent || '',
author: extractFB2Author(titleInfo),
language: titleInfo.querySelector('lang')?.textContent || 'en',
publisher: documentInfo?.querySelector('publisher')?.textContent || undefined,
isbn: undefined, // FB2 doesn't typically have ISBN
};
}
function extractFB2Author(titleInfo: Element): string {
const author = titleInfo.querySelector('author');
if (!author) return '';
const firstName = author.querySelector('first-name')?.textContent || '';
const lastName = author.querySelector('last-name')?.textContent || '';
const middleName = author.querySelector('middle-name')?.textContent || '';
const parts = [firstName, middleName, lastName].filter(Boolean);
return parts.join(' ') || 'Unknown';
}
function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF['toc'] {
const toc: EbookCIF['toc'] = [];
const body = xmlDoc.querySelector('body');
if (!body) return toc;
const sections = body.querySelectorAll(':scope > section');
let sectionIndex = 0;
for (const section of sections) {
const title = section.querySelector('title');
const titleText = title?.textContent.trim() || `Section ${sectionIndex + 1}`;
toc.push({
id: `section-${sectionIndex}`,
title: titleText,
href: `#section-${sectionIndex}`,
children: [],
});
sectionIndex++;
}
return toc;
}
function createFB2Spine(xmlDoc: XMLDocument): EbookCIF['spine'] {
const spine: EbookCIF['spine'] = [];
const body = xmlDoc.querySelector('body');
if (!body) return spine;
// Convert each section to HTML
const sections = body.querySelectorAll(':scope > section');
sections.forEach((section, index) => {
const htmlContent = convertFB2SectionToHTML(section, index);
spine.push({
id: `section-${index}`,
type: 'html',
content: htmlContent,
index,
});
});
return spine;
}
function convertFB2SectionToHTML(section: Element, index: number): string {
const title = section.querySelector('title');
let html = `<div id="section-${index}" class="fb2-section">`;
if (title) {
html += `<h1>${title.textContent}</h1>`;
}
// Convert paragraphs
const paragraphs = section.querySelectorAll('p');
paragraphs.forEach(p => {
html += `<p>${p.innerHTML}</p>`;
});
// Convert images
const images = section.querySelectorAll('image');
images.forEach(img => {
const href = img.getAttribute('l:href');
const alt = img.getAttribute('alt') || '';
if (href) {
html += `<img src="${href}" alt="${alt}" />`;
}
});
html += '</div>';
return html;
}
async function extractFB2Resources(xmlDoc: XMLDocument, fb2Blob: Blob): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
// FB2 can have embedded images (base64) or external references
const binary = xmlDoc.querySelector('binary');
if (binary) {
const contentType = binary.getAttribute('content-type');
const id = binary.getAttribute('id');
if (contentType && id && binary.textContent) {
// Decode base64
const base64Data = binary.textContent.trim();
const byteString = atob(base64Data);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: contentType });
resources.set(`#${id}`, blob);
}
}
return resources;
}
function calculateFB2Characters(xmlDoc: XMLDocument): number {
const body = xmlDoc.querySelector('body');
if (!body) return 0;
return body.textContent?.length || 0;
}
function generatePageBreaks(totalCharacters: number): number[] {
const breaks: number[] = [];
const charsPerPage = 1000;
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
breaks.push(i);
}
return breaks;
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractFB2Metadata(fb2Blob: Blob): Promise<Partial<EbookCIF['metadata']>> {
let xmlContent: string;
if (fb2Blob.type === 'application/zip') {
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
const fb2File = files.find(f => f.endsWith('.fb2'));
if (!fb2File) return {};
xmlContent = await zip.file(fb2File)!.async('text');
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
return extractFB2Metadata(xmlDoc);
}
```
---
### 5.4 Plain Text (TXT) Parser
**File:** `web/src/reader/parsers/txt-parser.ts`
```typescript
// TXT Parser - Wraps plain text in HTML structure
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseTXT(txtBlob: Blob): Promise<EbookCIF> {
const textContent = await txtBlob.text();
const metadata = extractTXTMetadata(txtBlob);
const toc = createTXTTOC(textContent);
const spine = createTXTSpine(textContent);
const resources = new Map(); // No external resources for plain text
const totalCharacters = textContent.length;
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractTXTMetadata(txtBlob: Blob): EbookCIF['metadata'] {
const filename = txtBlob.name || 'Unknown';
return {
title: filename.replace(/\.(txt|text)$/i, ''),
author: 'Unknown',
language: 'en',
};
}
function createTXTTOC(textContent: string): EbookCIF['toc'] {
// Try to detect chapters (simple heuristic)
const toc: EbookCIF['toc'] = [];
const lines = textContent.split('\n');
let chapterIndex = 0;
lines.forEach((line, index) => {
// Common chapter patterns
const chapterPattern = /^(chapter|part|section)\s+\d+/i;
if (chapterPattern.test(line.trim())) {
toc.push({
id: `chapter-${chapterIndex}`,
title: line.trim(),
href: `#chapter-${chapterIndex}`,
children: [],
});
chapterIndex++;
}
});
// If no chapters found, create single entry
if (toc.length === 0) {
toc.push({
id: 'full-text',
title: 'Full Text',
href: '#full-text',
children: [],
});
}
return toc;
}
function createTXTSpine(textContent: string): EbookCIF['spine'] {
// Convert plain text to HTML paragraphs
const lines = textContent.split('\n');
let htmlContent = '<div class="txt-content">';
lines.forEach(line => {
const trimmed = line.trim();
if (trimmed) {
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
} else {
htmlContent += '<br />';
}
});
htmlContent += '</div>';
return [{
id: 'full-text',
type: 'html',
content: htmlContent,
index: 0,
}];
}
function escapeHTML(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Removed - backend handles detailed position tracking
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractTXTMetadata(txtBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
return extractTXTMetadata(txtBlob);
}
```
---
### 5.5 HTML Parser
**File:** `web/src/reader/parsers/html-parser.ts`
```typescript
// HTML Parser - Wraps standalone HTML files
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseHTML(htmlBlob: Blob): Promise<EbookCIF> {
const htmlContent = await htmlBlob.text();
const metadata = extractHTMLMetadata(htmlBlob, htmlContent);
const toc = createHTMLTOC(htmlContent);
const spine = createHTMLSpine(htmlContent);
const resources = await extractHTMLResources(htmlBlob, htmlContent);
const totalCharacters = stripHTML(htmlContent).length;
const pageBreaks = generatePageBreaks(totalCharacters);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
pageBreaks,
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractHTMLMetadata(htmlBlob: Blob, htmlContent: string): EbookCIF['metadata'] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const title = doc.querySelector('title')?.textContent ||
htmlBlob.name.replace(/\.(html?|htm)$/i, '');
const metaAuthor = doc.querySelector('meta[name="author"]')?.getAttribute('content');
const metaLang = doc.querySelector('html')?.getAttribute('lang') || 'en';
return {
title,
author: metaAuthor || 'Unknown',
language: metaLang,
};
}
function createHTMLTOC(htmlContent: string): EbookCIF['toc'] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const toc: EbookCIF['toc'] = [];
// Try to find headings
const headings = doc.querySelectorAll('h1, h2, h3');
let headingIndex = 0;
headings.forEach(heading => {
toc.push({
id: `heading-${headingIndex}`,
title: heading.textContent || '',
href: `#${heading.id || `heading-${headingIndex}`}`,
children: [],
});
headingIndex++;
});
// If no headings, create single entry
if (toc.length === 0) {
toc.push({
id: 'full-document',
title: 'Full Document',
href: '#full-document',
children: [],
});
}
return toc;
}
function createHTMLSpine(htmlContent: string): EbookCIF['spine'] {
return [{
id: 'full-document',
type: 'html',
content: htmlContent,
index: 0,
}];
}
async function extractHTMLResources(htmlBlob: Blob, htmlContent: string): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
// Extract images
const images = doc.querySelectorAll('img[src]');
for (const img of Array.from(images)) {
const src = img.getAttribute('src');
if (!src) continue;
// Try to resolve relative URLs
if (src.startsWith('data:')) {
// Data URI - extract blob
const match = src.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
const mimeType = match[1];
const base64 = match[2];
const byteString = atob(base64);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
resources.set(src, blob);
}
}
// External resources would need to be fetched
// For now, skip them (browser will load them naturally)
}
return resources;
}
function stripHTML(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || '';
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractHTMLMetadata(htmlBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
const htmlContent = await htmlBlob.text();
return extractHTMLMetadata(htmlBlob, htmlContent);
}
```
---
### 5.6 HTML Renderer (Procedural)
**File:** `web/src/reader/ebook/html-renderer.ts`
```typescript
// HTML rendering with theme support, font loading, and image handling
// Procedural style: Functions, not classes
interface RendererConfig {
readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
fontSize: number;
lineHeight: number;
marginWidth: number;
textAlign: 'left' | 'justify';
columnCount: 1 | 2;
}
// ============================================================
// Main Render Function
// ============================================================
export async function renderHTMLDocument(
doc: HTMLDocument,
container: HTMLElement,
config: RendererConfig
): Promise<void> {
// Apply theme
applyHTMLTheme(container, config.readingTheme);
// Apply typography settings
applyHTMLTypography(container, config);
// Inject custom styles for reader
injectHTMLReaderStyles(container);
// Handle embedded fonts
await loadEmbeddedHTMLFonts(doc, container);
// Handle images
processHTMLImages(doc, container);
// Clear container and append content
container.innerHTML = '';
container.appendChild(doc.body);
// Apply column layout
applyHTMLColumnLayout(container, config.columnCount);
}
// ============================================================
// Theme Application
// ============================================================
function applyHTMLTheme(container: HTMLElement, theme: string): void {
const readingThemes: Record<string, Record<string, string>> = {
'light': {
'--bg-primary': '#ffffff',
'--text-primary': '#1a1a1a',
'--text-secondary': '#666666',
'--accent': '#0066cc'
},
'sepia': {
'--bg-primary': '#f4ecd8',
'--text-primary': '#5f4b32',
'--text-secondary': '#8b7355',
'--accent': '#8b4513'
},
'dark': {
'--bg-primary': '#1a1b26',
'--text-primary': '#c0caf5',
'--text-secondary': '#565f89',
'--accent': '#7aa2f7'
},
'night': {
'--bg-primary': '#0d1117',
'--text-primary': '#c9d1d9',
'--text-secondary': '#8b949e',
'--accent': '#58a6ff'
},
'high-contrast': {
'--bg-primary': '#000000',
'--text-primary': '#ffffff',
'--text-secondary': '#cccccc',
'--accent': '#ffff00'
}
};
const themeConfig = readingThemes[theme] || readingThemes['dark'];
for (const [key, value] of Object.entries(themeConfig)) {
container.style.setProperty(key, value);
}
}
function applyHTMLTypography(container: HTMLElement, config: RendererConfig): void {
const style = document.createElement('style');
const fontStack = getFontStack(config.readingFont);
style.textContent = `
.ebook-content {
font-family: ${fontStack};
font-size: ${config.fontSize}px;
line-height: ${config.lineHeight};
text-align: ${config.textAlign};
padding: 0 ${config.marginWidth}px;
max-width: 100%;
overflow-wrap: break-word;
}
.ebook-content p {
margin-bottom: 1em;
text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'};
}
.ebook-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
.ebook-content a {
color: var(--accent);
text-decoration: underline;
}
.ebook-content a:active {
color: var(--text-secondary);
}
`;
container.appendChild(style);
}
function injectHTMLReaderStyles(container: HTMLElement): void {
container.setAttribute('role', 'main');
container.setAttribute('aria-label', 'Book content');
}
async function loadEmbeddedHTMLFonts(doc: HTMLDocument, container: HTMLElement): Promise<void> {
const styleSheets = doc.querySelectorAll('style');
for (const sheet of styleSheets) {
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
for (const match of matches) {
const fontFace = match[1];
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
if (urlMatch) {
const fontUrl = urlMatch[1];
await loadHTMLFont(fontUrl, container);
}
}
}
}
async function loadHTMLFont(fontUrl: string, container: HTMLElement): Promise<void> {
const loadedFonts = container.dataset.loadedFonts ?
JSON.parse(container.dataset.loadedFonts) : [];
if (loadedFonts.includes(fontUrl)) return;
try {
const fontFace = new FontFace('custom-font', `url(${fontUrl})`);
await fontFace.load();
document.fonts.add(fontFace);
loadedFonts.push(fontUrl);
container.dataset.loadedFonts = JSON.stringify(loadedFonts);
} catch (error) {
console.error('Failed to load font:', fontUrl, error);
}
}
function processHTMLImages(doc: HTMLDocument): void {
const images = doc.querySelectorAll('img');
images.forEach((img) => {
img.setAttribute('loading', 'lazy');
if (!img.alt) {
img.alt = 'Image from book';
}
img.style.cursor = 'pointer';
img.addEventListener('click', () => {
showImageFullscreen(img.src);
});
});
}
function showImageFullscreen(src: string): void {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50';
modal.onclick = () => modal.remove();
const img = document.createElement('img');
img.src = src;
img.className = 'max-w-full max-h-full object-contain';
modal.appendChild(img);
document.body.appendChild(modal);
}
function applyHTMLColumnLayout(container: HTMLElement, columnCount: number): void {
if (columnCount === 2) {
container.style.columnCount = '2';
container.style.columnGap = '20px';
container.style.columnRule = '1px solid var(--text-secondary)';
} else {
container.style.columnCount = 'auto';
}
}
function getFontStack(font: string): string {
const stacks: 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 stacks[font] || stacks['literata'];
}
```
---
### 5.7 CFI Navigation (Procedural)
**File:** `web/src/reader/ebook/cfi-navigator.ts`
```typescript
// EPUB CFI (Canonical Fragment Identifier) navigation
// Reuses logic from internal/sync/format.go
// Procedural style: Functions, not classes
interface CFIComponent {
type: 'index' | 'indirection-step' | 'text-location';
value: number;
id?: string;
textOffset?: number;
}
// ============================================================
// CFI Parsing Functions
// ============================================================
export function parseCFI(cfi: string): CFIComponent[] {
const components: CFIComponent[] = [];
const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi;
const parts = cleanCFI.split('/').filter(Boolean);
for (const part of parts) {
const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/);
if (match) {
const component: CFIComponent = {
type: match[3] !== undefined ? 'text-location' : 'index',
value: parseInt(match[1], 10),
id: match[2],
textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined
};
components.push(component);
}
}
return components;
}
export function generateCFI(
spineIndex: number,
elementPath: number[],
textOffset: number = 0,
spineItemId?: string
): string {
let cfi = `/6/${spineIndex}`;
if (spineItemId) {
cfi += `[${spineItemId}]`;
}
for (const index of elementPath) {
cfi += `/${index}`;
}
if (textOffset > 0) {
cfi += `:${textOffset}`;
}
return cfi;
}
export function navigateToCFI(doc: Document, cfi: string): Element | Text | null {
const components = parseCFI(cfi);
if (components.length === 0) return null;
let current: Node | null = doc.body;
for (let i = 1; i < components.length; i++) {
const component = components[i];
if (component.type === 'index') {
if (current instanceof Element) {
const children = getElementChildren(current);
current = children[component.value] || null;
}
}
}
return current as Element | Text;
}
export function getSelectionCFI(doc: Document): string | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
// Build path to start container
const path: number[] = [];
let current: Node | null = startContainer;
while (current && current !== doc.body) {
const parent = current.parentElement;
if (parent) {
const siblings = getElementChildren(parent);
const index = siblings.indexOf(current as Element);
path.unshift(index);
}
current = parent;
}
const spineIndex = 0;
const textOffset = range.startOffset;
return generateCFI(spineIndex, path, textOffset);
}
export function getPercentageFromCFI(cfi: string): number {
const components = parseCFI(cfi);
const textLocation = components.find(c => c.type === 'text-location');
if (textLocation && textLocation.textOffset !== undefined) {
return Math.min((textLocation.textOffset / 10), 100);
}
return 0;
}
export function compareCFIs(cfi1: string, cfi2: string): number {
const components1 = parseCFI(cfi1);
const components2 = parseCFI(cfi2);
const maxLen = Math.max(components1.length, components2.length);
for (let i = 0; i < maxLen; i++) {
const comp1 = components1[i];
const comp2 = components2[i];
if (!comp1) return -1;
if (!comp2) return 1;
if (comp1.value !== comp2.value) {
return comp1.value - comp2.value;
}
}
return 0;
}
function getElementChildren(element: Element): Element[] {
return Array.from(element.children).filter(el =>
el.nodeType === Node.ELEMENT_NODE
) as Element[];
}
```
---
### 5.8 Typography Engine (Procedural)
**File:** `web/src/reader/ebook/typography-engine.ts`
```typescript
// Typography engine for ebook rendering
// Procedural style: Functions, not classes
interface TypographyConfig {
fontSize: number;
lineHeight: number;
textAlign: 'left' | 'justify';
hyphenate: boolean;
ligatures: boolean;
fontSmoothing: 'auto' | 'grayscale';
}
export function applyTypographyConfig(
element: HTMLElement,
config: TypographyConfig
): void {
// Enable/disable ligatures
setLigatures(element, config.ligatures);
// Enable/disable hyphenation
if (config.hyphenate) {
enableHyphenation(element);
}
// Apply justification settings
if (config.textAlign === 'justify') {
enableJustification(element);
}
// Apply font smoothing
element.style.fontSmooth = config.fontSmoothing;
}
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 enableHyphenation(element: HTMLElement): void {
element.style.hyphens = 'auto';
element.style.hyphenateLimitChars = '6 3 3';
// Add language attribute from EPUB metadata
const lang = element.closest('[data-language]')?.getAttribute('data-language') || 'en';
element.setAttribute('lang', lang);
}
function enableJustification(element: HTMLElement): void {
element.style.wordBreak = 'normal';
element.style.overflowWrap = 'break-word';
element.style.wordWrap = 'break-word';
element.style.letterSpacing = '0.01em';
}
export 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 function getWordCount(container: HTMLElement): number {
const content = container.querySelector('.ebook-content');
if (!content) return 0;
const text = content.textContent || '';
return text.split(/\s+/).length;
}
```
---
### 5.9 Ebook Search (Procedural)
**File:** `web/src/reader/ebook/search.ts`
```typescript
// Search within ebook content
// Procedural style: Functions, not classes
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
interface EbookSearchConfig {
epubPackage: EPUBPackage;
}
// ============================================================
// Main Search Function
// ============================================================
export async function searchEbook(
epubPackage: EPUBPackage,
query: string
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
// Search all spine items
for (const [index, spineItem] of epubPackage.spine.entries()) {
const doc = await getSpineItemDocument(epubPackage, spineItem);
if (!doc) continue;
const chapterTitle = getChapterTitle(spineItem);
// Search in text nodes
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(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem
): Promise<Document | null> {
try {
const content = await epubPackage.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: EPUBSpineItem): string {
// Extract title from spine item or use default
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: Text, offset: number): string {
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const parent = current.parentNode;
const siblings = Array.from(parent.childNodes)
.filter(n => n.nodeType === Node.ELEMENT_NODE);
const index = siblings.indexOf(current as Node);
path.unshift(index);
current = parent;
}
const spineIndex = 0; // Would come from parent context
return generateCFI(spineIndex, path, offset);
}
function extractSnippet(text: string, offset: number, length: number): string {
const contextBefore = 30;
const contextAfter = 50;
const start = Math.max(0, offset - contextBefore);
const end = Math.min(text.length, offset + length + contextAfter);
return text.slice(start, end);
}
```
**File:** `web/src/reader/ebook/html-renderer.ts`
```typescript
// HTML rendering with theme support, font loading, and image handling
interface RendererConfig {
readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; // Reading-optimized themes
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts
fontSize: number;
lineHeight: number;
marginWidth: number;
textAlign: 'left' | 'justify';
columnCount: 1 | 2; // Single or double column
}
// HTML rendering with theme support, font loading, and image handling
// Procedural implementation (no OOP)
const loadedFonts = new Set<string>();
async function renderDocument(
container: HTMLElement,
doc: HTMLDocument,
config: RendererConfig
): Promise<void> {
applyTheme(container, config.readingTheme);
applyTypography(container, config);
injectReaderStyles(container);
await loadEmbeddedFonts(doc);
processImages(doc);
container.innerHTML = '';
container.appendChild(doc.body);
applyColumnLayout(container, config);
}
function applyTheme(
container: HTMLElement,
readingTheme: RendererConfig['readingTheme']
): void {
const readingThemes: Record<string, Record<string, string>> = {
'light': {
'--bg-primary': '#ffffff',
'--text-primary': '#1a1a1a',
'--text-secondary': '#666666',
'--accent': '#0066cc'
},
'sepia': {
'--bg-primary': '#f4ecd8',
'--text-primary': '#5f4b32',
'--text-secondary': '#8b7355',
'--accent': '#8b4513'
},
'dark': {
'--bg-primary': '#1a1b26',
'--text-primary': '#c0caf5',
'--text-secondary': '#565f89',
'--accent': '#7aa2f7'
},
'night': {
'--bg-primary': '#0d1117',
'--text-primary': '#c9d1d9',
'--text-secondary': '#8b949e',
'--accent': '#58a6ff'
},
'high-contrast': {
'--bg-primary': '#000000',
'--text-primary': '#ffffff',
'--text-secondary': '#cccccc',
'--accent': '#ffff00'
}
};
const theme = readingThemes[readingTheme] || readingThemes['dark'];
for (const [key, value] of Object.entries(theme)) {
container.style.setProperty(key, value);
}
}
function applyTypography(container: HTMLElement, config: RendererConfig): void {
const style = document.createElement('style');
const fontStack = getFontStack(config.readingFont);
style.textContent = `
.ebook-content {
font-family: ${fontStack};
font-size: ${config.fontSize}px;
line-height: ${config.lineHeight};
text-align: ${config.textAlign};
padding: 0 ${config.marginWidth}px;
max-width: 100%;
overflow-wrap: break-word;
}
.ebook-content p {
margin-bottom: 1em;
text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'};
}
.ebook-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
.ebook-content a {
color: var(--accent);
text-decoration: underline;
}
.ebook-content a:active {
color: var(--text-secondary);
}
`;
container.appendChild(style);
}
function injectReaderStyles(container: HTMLElement): void {
container.setAttribute('role', 'main');
container.setAttribute('aria-label', 'Book content');
}
async function loadEmbeddedFonts(doc: HTMLDocument): Promise<void> {
const styleSheets = doc.querySelectorAll('style');
for (const sheet of styleSheets) {
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
for (const match of matches) {
const fontFace = match[1];
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
if (urlMatch) {
const fontUrl = urlMatch[1];
await loadFont(fontUrl);
}
}
}
}
async function loadFont(fontUrl: string): Promise<void> {
if (loadedFonts.has(fontUrl)) return;
try {
const fontFace = new FontFace('custom-font', `url(${fontUrl})`);
await fontFace.load();
document.fonts.add(fontFace);
loadedFonts.add(fontUrl);
} catch (error) {
console.error('Failed to load font:', fontUrl, error);
}
}
function processImages(doc: HTMLDocument): void {
const images = doc.querySelectorAll('img');
images.forEach((img) => {
img.setAttribute('loading', 'lazy');
if (!img.alt) {
img.alt = 'Image from book';
}
img.style.cursor = 'pointer';
img.addEventListener('click', () => {
showImageFullscreen(img.src);
});
});
}
function showImageFullscreen(src: string): void {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50';
modal.onclick = () => modal.remove();
const img = document.createElement('img');
img.src = src;
img.className = 'max-w-full max-h-full object-contain';
modal.appendChild(img);
document.body.appendChild(modal);
}
function applyColumnLayout(container: HTMLElement, config: RendererConfig): void {
if (config.columnCount === 2) {
container.style.columnCount = '2';
container.style.columnGap = `${config.marginWidth}px`;
container.style.columnRule = '1px solid var(--text-secondary)';
} else {
container.style.columnCount = 'auto';
}
}
function updateRendererConfig(
container: HTMLElement,
currentConfig: RendererConfig,
newConfig: Partial<RendererConfig>
): RendererConfig {
const updatedConfig = { ...currentConfig, ...newConfig };
const currentDoc = container.querySelector('.ebook-content')?.ownerDocument;
if (currentDoc) {
renderDocument(container, currentDoc as HTMLDocument, updatedConfig);
}
return updatedConfig;
}
```
### 5.10 Libre Reading Fonts (Bundled)
**8 Open Source Fonts Optimized for Extended Reading**
All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i).
**Font Directory:** `web/static/fonts/`
#### 5.10.1 Font Acquisition & Installation
**Automated Setup Script**
**File:** `scripts/fetch-reading-fonts.sh` (new file)
```bash
#!/bin/bash
# Fetch and prepare libre reading fonts for Bookhoard
# Usage: ./scripts/fetch-reading-fonts.sh
set -e
FONTS_DIR="web/static/fonts"
mkdir -p "$FONTS_DIR"
echo "📦 Downloading libre reading fonts for Bookhoard..."
# 1. Literata (v2.001 - latest stable)
echo "Downloading Literata..."
wget -O /tmp/literata.zip "https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip"
unzip -q /tmp/literata.zip -d /tmp/literata
mkdir -p "$FONTS_DIR/literata"
# Convert to WOFF2 using fonttools
for file in /tmp/literata/Static/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
fi
done
rm -rf /tmp/literata /tmp/literata.zip
# 2. Crimson Text (v1.102)
echo "Downloading Crimson Text..."
wget -O /tmp/crimson.zip "https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip"
unzip -q /tmp/crimson.zip -d /tmp/crimson
mkdir -p "$FONTS_DIR/crimson"
for file in /tmp/crimson/OTF/CrimsonPro-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Roman"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400.woff2" --flavor=woff2
elif [[ $basename == *"Roman-Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/crimson /tmp/crimson.zip
# 3. Source Serif 4 (v4.004)
echo "Downloading Source Serif 4..."
wget -O /tmp/source-serif.zip "https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip"
unzip -q /tmp/source-serif.zip -d /tmp/source-serif
mkdir -p "$FONTS_DIR/source-serif"
for file in /tmp/source-serif/OTF/SourceSerif4-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400.woff2" --flavor=woff2
elif [[ $basename == *"It"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldIt"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/source-serif /tmp/source-serif.zip
# 4. EB Garamond (v0.016)
echo "Downloading EB Garamond..."
wget -O /tmp/ebgaramond.zip "https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip"
unzip -q /tmp/ebgaramond.zip -d /tmp/ebgaramond
mkdir -p "$FONTS_DIR/eb-garamond"
for file in /tmp/ebgaramond/otf/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/ebgaramond /tmp/ebgaramond.zip
# 5. Libertinus Serif (v7.050)
echo "Downloading Libertinus Serif..."
wget -O /tmp/libertinus.zip "https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip"
unzip -q /tmp/libertinus.zip -d /tmp/libertinus
mkdir -p "$FONTS_DIR/libertinus"
for file in /tmp/libertinus/LibertinusSerif-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/libertinus /tmp/libertinus.zip
# 6. Noto Serif (v2.013 - subset to common languages only to reduce size)
echo "Downloading Noto Serif..."
wget -O /tmp/noto-serif.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Regular.ttf"
pyftsubset /tmp/noto-serif.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400.woff2" --flavor=woff2 --unicodes='U+0000-007F' --text-file="common-latin.txt"
wget -O /tmp/noto-serif-i.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Italic.ttf"
pyftsubset /tmp/noto-serif-i.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
wget -O /tmp/noto-serif-b.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf"
pyftsubset /tmp/noto-serif-b.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700.woff2" --flavor=woff2 --unicodes='U+0000-007F'
wget -O /tmp/noto-serif-bi.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf"
pyftsubset /tmp/noto-serif-bi.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
rm -f /tmp/noto-serif*.zip
# 7. Charis SIL (v6.200)
echo "Downloading Charis SIL..."
wget -O /tmp/charis.zip "https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip"
unzip -q /tmp/charis.zip -d /tmp/charis
mkdir -p "$FONTS_DIR/charis-sil"
for file in /tmp/charis/CharisSIL-6.200/*.ttf; do
basename=$(basename "$file" .ttf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/charis /tmp/charis.zip
# 8. IBM Plex Serif (v1.1.0)
echo "Downloading IBM Plex Serif..."
wget -O /tmp/ibm-plex.zip "https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip"
unzip -q /tmp/ibm-plex.zip -d /tmp/ibm-plex
mkdir -p "$FONTS_DIR/ibm-plex"
for file in /tmp/ibm-plex/OpenType/IBM-Plex-Serif/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400.woff2" --flavor=woff2
elif [[ $basename == *"TextItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400i.woff2" --flavor=woff2
elif [[ $basename == *"SemiBold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700.woff2" --flavor=woff2
elif [[ $basename == *"SemiBoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/ibm-plex /tmp/ibm-plex.zip
echo "✅ All fonts downloaded and converted to WOFF2"
echo "📊 Total size:"
du -sh "$FONTS_DIR"
echo "🔍 Verifying fonts..."
ls -lh "$FONTS_DIR"/*/
echo "✨ Font setup complete!"
```
**Manual Setup (Alternative)**
If you prefer manual setup or the script fails:
| Font | Version | Download URL | License |
|------|---------|-------------|---------|
| **Literata** | v2.001 | https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip | SIL OFL 1.1 |
| **Crimson Text** | v1.102 | https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip | SIL OFL 1.1 |
| **Source Serif 4** | v4.004 | https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip | SIL OFL 1.1 |
| **EB Garamond** | v0.016 | https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip | SIL OFL 1.1 |
| **Libertinus Serif** | v7.050 | https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip | SIL OFL 1.1 |
| **Noto Serif** | v2.013 | https://github.com/googlefonts/noto-fonts (subset to Latin-1) | SIL OFL 1.1 |
| **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 |
| **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 |
#### 5.10.2 Font Conversion Requirements
**Required Tools:**
```bash
# Python fonttools for WOFF2 conversion
pip install fonttools brotli
# Alternative: Google Fonts woff2 tool
git clone --recursive https://github.com/google/woff2.git
cd woff2
make
sudo cp woff2_compress /usr/local/bin/
sudo cp woff2_decompress /usr/local/bin/
```
**Conversion Commands:**
```bash
# Using fonttools (recommended)
pyftsubset input.otf --output-file=output.woff2 \
--flavor=woff2 \
--layout-features='*' \
--unicodes='U+0000-10FFFF'
# Using woff2_compress tool
woff2_compress input.otf output.woff2
```
#### 5.10.3 Font Verification
**Verify fonts are working:**
```bash
# List all fonts
ls -lh web/static/fonts/*/
# Check file sizes (should be ~100-200KB each)
du -h web/static/fonts/*/*.*
# Verify WOFF2 format
file web/static/fonts/*/*.woff2
# Should output: "WOFF2 font data"
```
**Add to git:**
```bash
# Add fonts to repository
git add web/static/fonts/
# Commit
git commit -m "feat: add 8 bundled libre reading fonts
- Literata (default)
- Crimson Text
- Source Serif 4
- EB Garamond
- Libertinus Serif
- Noto Serif
- Charis SIL
- IBM Plex Serif
All fonts use SIL Open Font License 1.1
WOFF2 format, ~1.2MB total"
```
#### 5.10.4 Font Loading in Templates
**File:** `templates/reader.templ` (updated)
Add to `<head>` section:
```go
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
...
}
```
#### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended)
If you don't want to bundle fonts (slower initial load, privacy concerns):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Literata:wght@0,400;0,700;1,400&family=Libertinus+Serif:wght@0,400;0,700;1,400&family=Noto+Serif:wght@0,400;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
```
**Why bundling is better:**
- ✅ Offline-ready (no network requests)
- ✅ Privacy (Google doesn't track usage)
- ✅ Faster (no DNS lookup, no TLS handshake)
- ✅ Control (exact versions, no breaking changes)
#### 5.10.6 Font Subsetting for Language Support
**Full Unicode vs. Latin-1 Subset:**
- **Full Unicode**: ~200KB per style (supports all languages)
- **Latin-1 Subset**: ~50KB per style (supports Western European languages)
**Recommendation:** Bundle full Unicode for most fonts, but subset Noto Serif to Latin-1 unless you need extensive language support.
**Subset Noto Serif (Latin-1 only):**
```bash
pyftsubset NotoSerif-Regular.ttf \
--output-file=NotoSerif-400.woff2 \
--flavor=woff2 \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215'
```
This reduces Noto Serif from ~180KB to ~50KB per style.
#### 5.10.7 Font Loading Performance
**Critical Rendering Path Optimization:**
```typescript
// Preload default font (Literata) in HTML head
<link rel="preload" href="/static/fonts/literata/Literata-400.woff2" as="font" type="font/woff2" crossorigin>
// Preload user's preferred font (from settings)
<link rel="preload" href="/static/fonts/crimson/CrimsonText-400.woff2" as="font" type="font/woff2" crossorigin>
```
**Lazy-load other fonts:**
```typescript
// Load other fonts on demand
async function loadFont(fontId: string): Promise<void> {
const font = READING_FONTS.find(f => f.id === fontId);
if (!font) return;
document.fonts.load(`16px "${font.stack}"`);
}
```
```
web/static/fonts/
├── literata/
│ ├── Literata-400.woff2 (200KB)
│ ├── Literata-400i.woff2 (200KB)
│ ├── Literata-700.woff2 (180KB)
│ └── Literata-700i.woff2 (180KB)
├── crimson/
│ ├── CrimsonText-400.woff2 (100KB)
│ ├── CrimsonText-400i.woff2 (100KB)
│ ├── CrimsonText-700.woff2 (95KB)
│ └── CrimsonText-700i.woff2 (95KB)
├── source-serif/
│ ├── SourceSerif4-400.woff2 (150KB)
│ ├── SourceSerif4-400i.woff2 (150KB)
│ ├── SourceSerif4-700.woff2 (145KB)
│ └── SourceSerif4-700i.woff2 (145KB)
├── eb-garamond/
│ ├── EBGaramond-400.woff2 (120KB)
│ ├── EBGaramond-400i.woff2 (120KB)
│ ├── EBGaramond-700.woff2 (115KB)
│ └── EBGaramond-700i.woff2 (115KB)
├── libertinus/
│ ├── LibertinusSerif-400.woff2 (150KB)
│ ├── LibertinusSerif-400i.woff2 (150KB)
│ ├── LibertinusSerif-700.woff2 (145KB)
│ └── LibertinusSerif-700i.woff2 (145KB)
├── noto-serif/
│ ├── NotoSerif-400.woff2 (180KB)
│ ├── NotoSerif-400i.woff2 (180KB)
│ ├── NotoSerif-700.woff2 (175KB)
│ └── NotoSerif-700i.woff2 (175KB)
├── charis-sil/
│ ├── CharisSIL-400.woff2 (130KB)
│ ├── CharisSIL-400i.woff2 (130KB)
│ ├── CharisSIL-700.woff2 (125KB)
│ └── CharisSIL-700i.woff2 (125KB)
└── ibm-plex/
├── IBMPlexSerif-400.woff2 (140KB)
├── IBMPlexSerif-400i.woff2 (140KB)
├── IBMPlexSerif-700.woff2 (135KB)
└── IBMPlexSerif-700i.woff2 (135KB)
```
**File:** `web/static/reader-fonts.css` (new file)
```css
/* Libre reading fonts for Bookhoard ebook reader */
/* Literata - Designed for Google Play Books */
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Crimson Text - Optimized for screen reading */
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Source Serif 4 - Adobe professional quality */
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* EB Garamond - Classic elegance */
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Libertinus Serif - Academic/technical */
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Noto Serif - Maximum language support */
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Charis SIL - Multilingual specialist */
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* IBM Plex Serif - Modern & versatile */
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
```
**Font Loading Strategy:**
**File:** `web/src/reader/ebook/font-loader.ts` (new file)
```typescript
// Font loading with performance optimization
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'
}
];
// Preload critical fonts (default font + user's last choice)
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}"`);
}
}
}
// Get font stack for CSS
function getFontStack(fontId: string): string {
const font = READING_FONTS.find(f => f.id === fontId);
return font?.stack || 'Literata, serif';
}
// All fonts bundled - no network requests needed
export { READING_FONTS, preloadFonts, getFontStack };
```
**Important Notes:**
- **UI Elements**: Use Bookhoard's existing font stack (not these reading fonts)
- **Ebook Content Only**: These fonts apply only to `.ebook-content` elements
- **Bundled**: All fonts ship with the app (~1.2MB total, WOFF2 compressed)
- **Offline Ready**: No network requests needed for font loading
- **Performance**: Preload default font (Literata) + user's preference
- **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK)
### 5.11 Typography Engine
**File:** `web/src/reader/ebook/typography-engine.ts`
```typescript
// Typography engine with font smoothing, hyphenation, and justification
interface TypographyConfig {
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts
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';
}
// Typography engine for ebook text rendering
// Procedural implementation (no OOP)
interface TypographyConfig {
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
fontSize: number;
lineHeight: number;
textAlign: 'left' | 'justify';
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
textIndent: number;
fontSmoothing: 'antialiased' | 'auto' | 'grayscale';
hyphenate: boolean;
ligatures: boolean;
}
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: ${config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'};
`);
if (config.hyphenate) {
enableHyphenation(container, content as HTMLElement);
}
setLigatures(content as HTMLElement, config.ligatures);
if (config.textAlign === 'justify') {
enableJustification(content as HTMLElement);
}
}
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);
}
function getWordCount(container: HTMLElement): number {
const content = container.querySelector('.ebook-content');
if (!content) return 0;
const text = content.textContent || '';
return text.split(/\s+/).length;
}
```
### 5.12 Search Within Ebook
**File:** `web/src/reader/ebook/search.ts`
```typescript
// Search within ebook content
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
// Search within ebook content
// Procedural implementation (no OOP)
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
async function searchEbook(
epubPackage: EPUBPackage,
query: string
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
for (const [index, spineItem] of epubPackage.spine.entries()) {
const doc = await getSpineItemDocument(epubPackage, spineItem);
if (!doc) continue;
const chapterTitle = getChapterTitle(epubPackage, 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 = generateSearchCFI(node, foundAt);
const snippet = extractSearchSnippet(text, foundAt, query.length);
results.push({
cfi,
snippet,
chapterTitle
});
foundAt += lowerQuery.length;
}
}
}
return results;
}
async function getSpineItemDocument(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem
): Promise<Document | null> {
try {
const content = await epubPackage.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 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 generateSearchCFI(node: Text, offset: number): string {
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const parent = current.parentNode;
const siblings = Array.from(parent.childNodes)
.filter(n => n.nodeType === Node.ELEMENT_NODE);
const index = siblings.indexOf(current as Node);
path.unshift(index);
current = parent;
}
const spineIndex = 0;
return generateCFI(spineIndex, path, offset);
}
function extractSearchSnippet(text: string, offset: number, length: number): string {
const contextBefore = 30;
const contextAfter = 50;
const start = Math.max(0, offset - contextBefore);
const end = Math.min(text.length, offset + length + contextAfter);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
return snippet;
}
function getChapterTitle(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem
): string {
for (const toc of epubPackage.toc) {
if (toc.href === spineItem.href) {
return toc.label;
}
for (const child of toc.children) {
if (child.href === spineItem.href) {
return child.label;
}
}
}
return 'Chapter ' + (epubPackage.spine.indexOf(spineItem) + 1);
}
```
### 5.13 Copy Text Handler
**File:** `web/src/reader/ebook/copy-handler.ts`
```typescript
// Handle text copying with citation
// Handle text copying with citation
// Procedural implementation (no OOP)
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);
}
});
}
```
### 5.14 View Modes
**File:** `web/src/reader/ebook/view-modes.ts`
```typescript
// Different viewing modes for ebooks
type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';
// Different viewing modes for ebooks
// Procedural implementation (no OOP)
type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';
interface ViewModeState {
currentMode: ViewMode;
currentPage: number;
}
function setViewMode(container: HTMLElement, mode: ViewMode): void {
const content = container.querySelector('.ebook-content');
if (!content) return;
content.classList.remove(
'paginated',
'scrolled',
'single-column',
'double-column'
);
switch (mode) {
case 'paginated':
applyPaginatedMode(container, content as HTMLElement);
break;
case 'scrolled':
applyScrolledMode(container, content as HTMLElement);
break;
case 'single-column':
applySingleColumn(content as HTMLElement);
break;
case 'double-column':
applyDoubleColumn(content as HTMLElement);
break;
}
}
function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void {
element.classList.add('paginated');
element.style.height = '100vh';
element.style.overflow = 'hidden';
element.style.columnCount = '1';
element.style.columnGap = '0';
enablePagination(container, element);
}
function applyScrolledMode(container: HTMLElement, element: HTMLElement): void {
element.classList.add('scrolled');
element.style.height = 'auto';
element.style.overflowY = 'auto';
element.style.columnCount = '1';
disablePagination(container);
}
function applySingleColumn(element: HTMLElement): void {
element.classList.add('single-column');
element.style.columnCount = '1';
element.style.columnGap = '0';
element.style.maxWidth = '800px';
element.style.margin = '0 auto';
}
function applyDoubleColumn(element: HTMLElement): void {
element.classList.add('double-column');
element.style.columnCount = '2';
element.style.columnGap = '60px';
element.style.columnRule = '1px solid var(--text-secondary)';
element.style.maxWidth = '1400px';
element.style.margin = '0 auto';
}
function enablePagination(container: HTMLElement, element: HTMLElement): void {
const totalHeight = element.scrollHeight;
const pageHeight = element.clientHeight;
const pageCount = Math.ceil(totalHeight / pageHeight);
addPaginationControls(container, pageCount);
}
function disablePagination(container: HTMLElement): void {
const controls = container.querySelector('.pagination-controls');
controls?.remove();
}
function addPaginationControls(container: HTMLElement, pageCount: number): ViewModeState {
let currentPage = 1;
const controls = document.createElement('div');
controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t';
controls.innerHTML = `
<button class="prev-page" ${currentPage === 1 ? 'disabled' : ''}>← Previous</button>
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
<button class="next-page" ${currentPage === pageCount ? 'disabled' : ''}>Next →</button>
`;
controls.querySelector('.prev-page')?.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
goToPage(container, currentPage);
}
});
controls.querySelector('.next-page')?.addEventListener('click', () => {
if (currentPage < pageCount) {
currentPage++;
goToPage(container, currentPage);
}
});
container.appendChild(controls);
return { currentMode: 'paginated', currentPage };
}
function goToPage(container: HTMLElement, pageNumber: number): void {
const content = container.querySelector('.ebook-content') as HTMLElement;
if (!content) return;
const pageHeight = content.clientHeight;
const scrollTop = (pageNumber - 1) * pageHeight;
content.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
const pageInfo = container.querySelector('.page-info');
if (pageInfo) {
pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`;
}
}
function getTotalPageCount(container: HTMLElement): number {
const content = container.querySelector('.ebook-content') as HTMLElement;
if (!content) return 1;
const totalHeight = content.scrollHeight;
const pageHeight = content.clientHeight;
return Math.ceil(totalHeight / pageHeight);
}
```
---
## 6. PDF Reader Implementation
### 6.1 PDF.js Integration (Procedural)
**File:** `web/src/reader/pdf/pdfjs-wrapper.ts`
```typescript
// Mozilla pdf.js integration for PDF rendering
// Procedural style: Functions, not classes
import * as pdfjsLib from 'pdfjs-dist';
// ============================================================
// PDF.js Configuration
// ============================================================
export function configurePDFJS(): void {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.mjs';
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = '/static/standard_fonts/';
pdfjsLib.GlobalWorkerOptions.cMapUrl = '/static/cmaps/';
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
}
// ============================================================
// PDF Document State
// ============================================================
interface PDFDocumentState {
doc: pdfjsLib.PDFDocumentProxy | null;
pages: Map<number, pdfjsLib.PDFPageProxy>;
metadata: PDFMetadata | null;
}
interface PDFMetadata {
title: string;
author: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creationDate?: Date;
modificationDate?: Date;
pageCount: number;
}
let pdfState: PDFDocumentState = {
doc: null,
pages: new Map(),
metadata: null,
};
// ============================================================
// Document Loading
// ============================================================
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
// Cleanup previous document
unloadPDFDocument();
const arrayBuffer = await pdfBlob.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({
data: arrayBuffer,
});
pdfState.doc = await loadingTask.promise;
// Extract metadata
const metadata = await pdfState.doc.getMetadata().catch(() => null);
const info = metadata?.info || {};
pdfState.metadata = {
title: info.Title || 'Untitled',
author: info.Author || 'Unknown',
subject: info.Subject,
keywords: info.Keywords,
creator: info.Creator,
producer: info.Producer,
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
pageCount: pdfState.doc.numPages,
};
return pdfState.metadata;
}
export async function getPDFPage(pageNumber: number): Promise<pdfjsLib.PDFPageProxy> {
if (!pdfState.doc) {
throw new Error('PDF document not loaded');
}
// Check cache
if (pdfState.pages.has(pageNumber)) {
return pdfState.pages.get(pageNumber)!;
}
// Load page
const page = await pdfState.doc.getPage(pageNumber);
pdfState.pages.set(pageNumber, page);
return page;
}
export async function getPDFPageText(pageNumber: number): Promise<any> {
const page = await getPDFPage(pageNumber);
return await page.getTextContent();
}
export function getPDFMetadata(): PDFMetadata | null {
return pdfState.metadata;
}
export function getPDFPageCount(): number {
return pdfState.doc?.numPages || 0;
}
export function unloadPDFDocument(): void {
pdfState.pages.clear();
pdfState.doc = null;
pdfState.metadata = null;
}
export function unloadPDFPage(pageNumber: number): void {
pdfState.pages.delete(pageNumber);
}
```
---
### 6.2 Text Layer Renderer (Procedural)
**File:** `web/src/reader/pdf/text-layer-renderer.ts`
```typescript
// Text layer rendering for PDF text selection and highlighting
// Procedural style: Functions, not classes
// ============================================================
// Render Functions
// ============================================================
export function renderTextLayer(
container: HTMLElement,
viewport: any,
textContent: any,
config: TextLayerConfig
): void {
// Clear container
container.innerHTML = '';
// Apply styles
applyTextLayerStyles(container, config);
// Render text items
const { items } = textContent;
items.forEach((item: any, index: number) => {
if (typeof item === 'string') return;
const textDiv = createTextDiv(item, viewport, index);
container.appendChild(textDiv);
});
}
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
const div = document.createElement('div');
div.className = 'pdf-text-layer-text';
div.textContent = item.str;
div.dataset.index = index.toString();
// Position the text div
const tx = pdfjsLib.Util.transform(
viewport.transform,
item.transform
);
const fontSize = Math.sqrt((tx[0] * tx[0]) + (tx[1] * tx[1]));
div.style.left = `${tx[4]}px`;
div.style.top = `${tx[5] - fontSize}px`;
div.style.fontSize = `${fontSize}px`;
div.style.fontFamily = item.fontName || 'sans-serif';
// Handle text direction
if (item.dir === 'ttb') {
div.style.writingMode = 'vertical-rl';
}
return div;
}
interface TextLayerConfig {
theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
}
function applyTextLayerStyles(container: HTMLElement, config: TextLayerConfig): void {
const style = document.createElement('style');
style.textContent = getTextLayerCSS(config.theme);
container.appendChild(style);
}
function getTextLayerCSS(theme: string): string {
const colors = getThemeColors(theme);
return `
.pdf-text-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 1;
line-height: 1;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pdf-text-layer-text {
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
color: transparent;
pointer-events: auto;
}
.pdf-text-layer-text::selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-text-layer-text::-moz-selection {
background: ${colors.highlight};
color: transparent;
}
.pdf-highlight-overlay {
position: absolute;
background-color: ${colors.highlight};
mix-blend-mode: multiply;
pointer-events: none;
}
`;
}
function getThemeColors(theme: string): { highlight: string } {
const themes: Record<string, { highlight: string }> = {
'light': { highlight: 'rgba(255, 255, 0, 0.3)' },
'sepia': { highlight: 'rgba(255, 200, 0, 0.4)' },
'dark': { highlight: 'rgba(255, 255, 0, 0.3)' },
'night': { highlight: 'rgba(100, 150, 255, 0.3)' },
'high-contrast': { highlight: 'rgba(255, 255, 0, 0.5)' }
};
return themes[theme] || themes['dark'];
}
// ============================================================
// Selection Functions
// ============================================================
export function getPDFTextSelection(): { text: string; range: Range } | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
return { text, range };
}
export function getPDFSelectionRects(): DOMRect[] {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return [];
const rects: DOMRect[] = [];
const range = selection.getRangeAt(0);
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return rects;
}
```
---
### 6.3 Annotation Layer (Procedural)
**File:** `web/src/reader/pdf/annotation-layer.ts`
```typescript
// Annotation layer for rendering highlights and notes on PDFs
// Procedural style: Functions, not classes
interface PDFHighlight {
id: string;
pageNumber: number;
rects: DOMRect[];
text: string;
color: string;
noteId?: string;
}
const highlights = new Map<string, HTMLElement>();
export function renderPDFHighlights(
container: HTMLElement,
highlightList: PDFHighlight[]
): void {
// Clear existing highlights
clearPDFHighlights(container);
for (const highlight of highlightList) {
renderSinglePDFHighlight(container, highlight);
}
}
function renderSinglePDFHighlight(container: HTMLElement, highlight: PDFHighlight): void {
const overlay = document.createElement('div');
overlay.className = 'pdf-highlight-annotation';
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color);
// Position highlight rectangles
for (const rect of highlight.rects) {
const rectDiv = document.createElement('div');
rectDiv.className = 'pdf-highlight-rect';
rectDiv.style.left = `${rect.left}px`;
rectDiv.style.top = `${rect.top}px`;
rectDiv.style.width = `${rect.width}px`;
rectDiv.style.height = `${rect.height}px`;
overlay.appendChild(rectDiv);
}
// Add click handler for note popup
if (highlight.noteId) {
overlay.style.cursor = 'pointer';
overlay.addEventListener('click', () => {
showNotePopup(highlight);
});
}
// Add hover effect
overlay.addEventListener('mouseenter', () => {
overlay.style.opacity = '0.8';
});
overlay.addEventListener('mouseleave', () => {
overlay.style.opacity = '0.5';
});
container.appendChild(overlay);
highlights.set(highlight.id, overlay);
}
function parseColor(color: string): string {
if (color.startsWith('#')) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.4)`;
}
return color;
}
function showNotePopup(highlight: PDFHighlight): void {
console.log('Show note for highlight:', highlight.id);
}
export function clearPDFHighlights(container: HTMLElement): void {
highlights.forEach(element => element.remove());
highlights.clear();
}
export function removePDFHighlight(highlightId: string): void {
const element = highlights.get(highlightId);
if (element) {
element.remove();
highlights.delete(highlightId);
}
}
```
---
### 6.4 PDF Navigation (Procedural)
**File:** `web/src/reader/pdf/pdf-navigation.ts`
```typescript
// PDF navigation: page turning, zoom, fit modes
// Procedural style: Functions, not classes
type PageFitMode = 'fit-width' | 'fit-page' | 'fit-height' | 'none';
interface PDFNavigationState {
currentPage: number;
totalPages: number;
currentScale: number;
fitMode: PageFitMode;
scrollContainer: HTMLElement | null;
}
let navState: PDFNavigationState = {
currentPage: 1,
totalPages: 0,
currentScale: 1.0,
fitMode: 'fit-width',
scrollContainer: null,
};
// ============================================================
// Initialization
// ============================================================
export function initializePDFNavigation(
container: HTMLElement,
onPageChange: (pageNumber: number) => void,
onZoomChange: (scale: number) => void
): void {
navState.scrollContainer = container.querySelector('.pdf-scroll-container') || container;
setupPDFKeyboardNav(onPageChange);
setupPDFScrollTracking(onPageChange);
}
export function setPDFTotalPages(totalPages: number): void {
navState.totalPages = totalPages;
}
// ============================================================
// Page Navigation
// ============================================================
export function goToPDFPage(pageNumber: number): void {
if (pageNumber < 1 || pageNumber > navState.totalPages) return;
navState.currentPage = pageNumber;
const callback = (window as any).pdfOnPageChange;
if (callback) callback(pageNumber);
scrollToPDFPage(pageNumber);
}
export function nextPDFPage(): void {
if (navState.currentPage < navState.totalPages) {
goToPDFPage(navState.currentPage + 1);
}
}
export function previousPDFPage(): void {
if (navState.currentPage > 1) {
goToPDFPage(navState.currentPage - 1);
}
}
function scrollToPDFPage(pageNumber: number): void {
if (!navState.scrollContainer) return;
const pageElement = navState.scrollContainer.querySelector(`[data-page-number="${pageNumber}"]`);
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// ============================================================
// Zoom Controls
// ============================================================
export function setPDFZoom(scale: number): void {
navState.currentScale = scale;
navState.fitMode = 'none';
const callback = (window as any).pdfOnZoomChange;
if (callback) callback(scale);
updatePDFZoom();
}
export function setPDFFitMode(mode: PageFitMode): void {
navState.fitMode = mode;
updatePDFZoom();
}
export function zoomPDFIn(): void {
setPDFZoom(navState.currentScale * 1.2);
}
export function zoomPDFOut(): void {
setPDFZoom(navState.currentScale / 1.2);
}
function updatePDFZoom(): void {
if (!navState.scrollContainer) return;
const pages = navState.scrollContainer.querySelectorAll('.pdf-page-container');
pages.forEach((page: Element) => {
(page as HTMLElement).style.transform = `scale(${navState.currentScale})`;
(page as HTMLElement).style.transformOrigin = 'top center';
});
}
// ============================================================
// Keyboard Navigation
// ============================================================
function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void {
document.addEventListener('keydown', handlePDFKeyDown);
}
function handlePDFKeyDown(e: KeyboardEvent): void {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
nextPDFPage();
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
previousPDFPage();
break;
case 'Home':
e.preventDefault();
goToPDFPage(1);
break;
case 'End':
e.preventDefault();
goToPDFPage(navState.totalPages);
break;
}
}
// ============================================================
// Scroll Tracking
// ============================================================
function setupPDFScrollTracking(onPageChange: (pageNumber: number) => void): void {
if (!navState.scrollContainer) return;
let scrollTimeout: NodeJS.Timeout;
navState.scrollContainer.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
updateCurrentPageFromScroll(onPageChange);
}, 100);
});
}
function updateCurrentPageFromScroll(onPageChange: (pageNumber: number) => void): void {
if (!navState.scrollContainer) return;
const scrollTop = navState.scrollContainer.scrollTop;
const containerHeight = navState.scrollContainer.clientHeight;
const pages = navState.scrollContainer.querySelectorAll('[data-page-number]');
let maxVisibility = 0;
let mostVisiblePage = navState.currentPage;
pages.forEach((page) => {
const element = page as HTMLElement;
const pageTop = element.offsetTop;
const pageBottom = pageTop + element.offsetHeight;
const visibleTop = Math.max(scrollTop, pageTop);
const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > maxVisibility) {
maxVisibility = visibleHeight;
mostVisiblePage = parseInt(element.dataset.pageNumber || '1');
}
});
if (mostVisiblePage !== navState.currentPage) {
navState.currentPage = mostVisiblePage;
onPageChange(mostVisiblePage);
}
}
// ============================================================
// Getters
// ============================================================
export function getCurrentPDFPage(): number {
return navState.currentPage;
}
export function getTotalPDFPages(): number {
return navState.totalPages;
}
export function getPDFScale(): number {
return navState.currentScale;
}
```
### 6.5 PDF Search
**File:** `web/src/reader/pdf/pdf-search.ts`
```typescript
// Full-text search within PDF documents
import { PDFDocumentProxy } from 'pdfjs-dist';
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
// Full-text search within PDF documents
// Procedural implementation (no OOP)
interface SearchResult {
pageNumber: number;
text: string;
index: number;
context: string;
}
interface PDFSearchState {
doc: PDFDocumentProxy | null;
searchResults: SearchResult[];
currentResultIndex: number;
}
async function initializePDFSearch(doc: PDFDocumentProxy): Promise<PDFSearchState> {
return {
doc,
searchResults: [],
currentResultIndex: 0
};
}
async function searchPDF(state: PDFSearchState, query: string): Promise<PDFSearchState> {
if (!state.doc) return state;
const searchResults: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const textContent = await page.getTextContent();
let fullText = '';
const textItems = textContent.items.map(item => {
if (typeof item === 'string') return '';
fullText += item.str;
return item.str;
});
const pageText = textItems.join(' ');
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
searchResults.push(...matches);
}
return { ...state, searchResults };
}
function findSearchMatches(
text: string,
query: string,
pageNumber: number
): SearchResult[] {
const matches: SearchResult[] = [];
const lowerText = text.toLowerCase();
let index = 0;
while ((index = lowerText.indexOf(query, index)) !== -1) {
const start = Math.max(0, index - 50);
const end = Math.min(text.length, index + query.length + 50);
const context = text.slice(start, end);
matches.push({
pageNumber,
text: text.slice(index, index + query.length),
index,
context
});
index += query.length;
}
return matches;
}
function goToNextSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex]
};
}
function goToPreviousSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } {
if (state.searchResults.length === 0) {
return { ...state, result: null };
}
const newIndex = (state.currentResultIndex - 1 + state.searchResults.length) % state.searchResults.length;
return {
...state,
currentResultIndex: newIndex,
result: state.searchResults[newIndex]
};
}
function getSearchResultCount(state: PDFSearchState): number {
return state.searchResults.length;
}
function clearSearchResults(state: PDFSearchState): PDFSearchState {
return {
...state,
searchResults: [],
currentResultIndex: 0
};
}
```
### 6.6 Page Cache (Pre-rendering)
**File:** `web/src/reader/pdf/page-cache.ts`
```typescript
// 5-page ahead cache for PDF pages
// Pre-renders canvas and text layer for nearby pages
import { PDFPageProxy, PageViewport } from 'pdfjs-dist';
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
// 5-page ahead cache for PDF pages
// Procedural implementation (no OOP)
interface CachedPage {
pageNumber: number;
canvas: HTMLCanvasElement;
textLayer: HTMLElement;
viewport: PageViewport;
timestamp: number;
}
interface PDFPageCacheState {
cache: Map<number, CachedPage>;
maxCacheSize: number;
renderCallbacks: Map<number, Array<() => void>>;
}
function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState {
return {
cache: new Map(),
maxCacheSize,
renderCallbacks: new Map()
};
}
async function getCachedPage(
state: PDFPageCacheState,
pageNumber: number,
renderFn: (pageNumber: number) => Promise<{ canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport }>
): Promise<PDFPageCacheState & { page: CachedPage }> {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.timestamp = Date.now();
return { ...state, page: cached };
}
const { canvas, textLayer, viewport } = await renderFn(pageNumber);
const cachedPage: CachedPage = {
pageNumber,
canvas,
textLayer,
viewport,
timestamp: Date.now()
};
const newCache = new Map(state.cache);
newCache.set(pageNumber, cachedPage);
const callbacks = state.renderCallbacks.get(pageNumber);
if (callbacks) {
callbacks.forEach(cb => cb());
const newCallbacks = new Map(state.renderCallbacks);
newCallbacks.delete(pageNumber);
return { ...state, cache: newCache, renderCallbacks: newCallbacks, page: cachedPage };
}
return { ...state, cache: newCache, page: cachedPage };
}
function preloadPages(
state: PDFPageCacheState,
currentPage: number,
totalPages: number
): PDFPageCacheState {
for (let i = 1; i <= state.maxCacheSize; i++) {
const pageNumber = currentPage + i;
if (pageNumber <= totalPages && !state.cache.has(pageNumber)) {
triggerPreload(pageNumber);
}
}
return state;
}
function triggerPreload(pageNumber: number): void {
console.log('Preloading page:', pageNumber);
}
function invalidatePage(
state: PDFPageCacheState,
pageNumber: number
): PDFPageCacheState {
const cached = state.cache.get(pageNumber);
if (cached) {
cached.canvas.remove();
cached.textLayer.remove();
const newCache = new Map(state.cache);
newCache.delete(pageNumber);
return { ...state, cache: newCache };
}
return state;
}
function clearPageCache(state: PDFPageCacheState): PDFPageCacheState {
state.cache.forEach(page => {
page.canvas.remove();
page.textLayer.remove();
});
return {
...state,
cache: new Map()
};
}
function onPageRendered(
state: PDFPageCacheState,
pageNumber: number,
callback: () => void
): PDFPageCacheState {
const newCallbacks = new Map(state.renderCallbacks);
if (!newCallbacks.has(pageNumber)) {
newCallbacks.set(pageNumber, []);
}
newCallbacks.get(pageNumber)!.push(callback);
return { ...state, renderCallbacks: newCallbacks };
}
```
### 6.7 PDF Text Selection (Uses Backend API)
**File:** `web/src/reader/pdf/pdf-text-selection.ts`
```typescript
// PDF text selection - Uses backend API for highlight creation
// Backend handles all position calculations for PDFs
// Procedural style: Functions, not classes
interface PDFTextSelection {
pageNumber: number;
text: string;
rects: DOMRect[];
}
// ============================================================
// Get PDF Text Selection
// ============================================================
export function getPDFTextSelection(): PDFTextSelection | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const text = range.toString();
if (!text) return null;
// Get page number from selection
const pageElement = range.commonAncestorContainer.closest?.('[data-page-number]');
const pageNumber = pageElement?.dataset.pageNumber
? parseInt(pageElement.dataset.pageNumber)
: getCurrentPDFPage();
// Get bounding rectangles
const rects: DOMRect[] = [];
for (const rect of range.getClientRects()) {
rects.push(rect);
}
return {
pageNumber,
text,
rects
};
}
// ============================================================
// Create PDF Highlight (Backend Calculates Position)
// ============================================================
export async function createPDFHighlight(
mediaItemId: string,
selection: PDFTextSelection,
color: string
): Promise<Highlight> {
const selectionData = {
selection_text: selection.text,
page_number: selection.pageNumber,
rects: selection.rects.map(rect => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
})),
color
};
// Send to backend - backend calculates all position formats
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selectionData)
});
if (!response.ok) {
throw new Error(`Failed to create highlight: ${response.statusText}`);
}
return await response.json();
}
// ============================================================
// Load and Render PDF Highlights (Backend Provides Positions)
// ============================================================
export async function loadAndRenderPDFHighlights(
mediaItemId: string,
container: HTMLElement
): Promise<void> {
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
if (!response.ok) return [];
const highlights: Highlight[] = await response.json();
for (const highlight of highlights) {
renderPDFHighlight(container, highlight);
}
}
function renderPDFHighlight(container: HTMLElement, highlight: Highlight): void {
// Backend provides position data for PDF highlights
// Check which position format is available
if (highlight.start_position && highlight.start_position.startsWith('pdf:page:')) {
// Backend calculated page-based position
renderPDFHighlightByPosition(container, highlight);
} else if (highlight.percentage_start !== null) {
// Backend calculated percentage position
renderPDFHighlightByPercentage(container, highlight);
}
}
function renderPDFHighlightByPosition(container: HTMLElement, highlight: Highlight): void {
// Parse position string: "pdf:page:45:offset:123"
const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/);
if (!match) return;
const pageNumber = parseInt(match[1], 10);
const offset = parseInt(match[2], 10);
// Find the page element
const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`);
if (!pageElement) return;
// Get text content at offset
const textContent = pageElement.querySelector('.pdf-text-layer')?.textContent;
if (!textContent) return;
// Find the text at this offset
const textBefore = textContent.substring(0, offset);
const startChar = textBefore.length;
const endChar = startChar + (highlight.selection_text?.length || 10);
if (startChar < textContent.length && endChar <= textContent.length) {
applyHighlightToTextContent(
pageElement as HTMLElement,
startChar,
endChar,
highlight.color
);
}
}
function renderPDFHighlightByPercentage(container: HTMLElement, highlight: Highlight): void {
// Backend provides percentage - estimate position
const percentage = highlight.percentage_start || 0;
// Find spine item closest to this percentage
const totalPages = container.querySelectorAll('[data-page-number]').length;
const targetPage = Math.ceil(percentage * totalPages);
const pageElement = container.querySelector(`[data-page-number="${targetPage}"]`);
if (pageElement) {
// Highlight entire page (coarse-grained)
applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color);
}
}
function applyHighlightToTextContent(
element: HTMLElement,
startChar: number,
endChar: number,
color: string
): void {
const text = element.textContent || '';
const before = text.substring(0, startChar);
const selection = text.substring(startChar, endChar);
const after = text.substring(endChar);
element.textContent = before + selection + after;
// Use a mark to wrap the selected text
element.innerHTML = `${before}<mark style="background-color: ${addAlphaToColor(color, 0.4)}">${selection}</mark>${after}`;
}
```
---
### 6.8 PDF Outline/TOC Navigation
**File:** `web/src/reader/pdf/pdf-outline.ts`
```typescript
// PDF outline/TOC navigation
// Procedural implementation (no OOP)
interface PDFOutlineNode {
id: string;
title: string;
destination: number | null;
pageNumber?: number;
children: PDFOutlineNode[];
expanded: boolean;
}
interface PDFOutlineState {
doc: PDFDocumentProxy | null;
outline: PDFOutlineNode[];
flatMap: Map<string, number>;
}
async function initializePDFOutline(doc: PDFDocumentProxy): Promise<PDFOutlineState> {
const state: PDFOutlineState = {
doc,
outline: [],
flatMap: new Map()
};
return await loadPDFOutline(state);
}
async function loadPDFOutline(state: PDFOutlineState): Promise<PDFOutlineState> {
if (!state.doc) return state;
const pdfOutline = await state.doc.getOutline();
if (!pdfOutline || pdfOutline.length === 0) {
return { ...state, outline: [] };
}
const outline = await parseOutlineNodes(state, pdfOutline);
return { ...state, outline };
}
async function parseOutlineNodes(
state: PDFOutlineState,
nodes: OutlineTreeNode[]
): Promise<PDFOutlineNode[]> {
const result: PDFOutlineNode[] = [];
for (const node of nodes) {
const outlineNode: PDFOutlineNode = {
id: generateOutlineId(),
title: node.title,
destination: null,
children: [],
expanded: false
};
if (node.dest) {
const pageNumber = await resolvePDFDestination(state, node.dest);
outlineNode.destination = pageNumber;
outlineNode.pageNumber = pageNumber;
state.flatMap.set(node.title, pageNumber);
}
if (node.items && node.items.length > 0) {
outlineNode.children = await parseOutlineNodes(state, node.items);
}
result.push(outlineNode);
}
return result;
}
async function resolvePDFDestination(
state: PDFOutlineState,
dest: string | any[]
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === 'string') {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === 'object' && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === 'number') {
return ref + 1;
}
return 1;
} catch (error) {
console.error('Failed to resolve destination:', dest, error);
return 1;
}
}
function generateOutlineId(): string {
return `outline-${Math.random().toString(36).substr(2, 9)}`;
}
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
return state.outline;
}
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
return state.flatMap;
}
function getCurrentChapter(
state: PDFOutlineState,
pageNumber: number
): PDFOutlineNode | null {
return findChapterForPage(state.outline, pageNumber);
}
function findChapterForPage(
nodes: PDFOutlineNode[],
pageNumber: number
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.pageNumber && node.pageNumber <= pageNumber) {
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
return node;
}
if (node.children.length > 0) {
const childMatch = findChapterForPage(node.children, pageNumber);
if (childMatch) return childMatch;
}
}
return null;
}
function toggleOutlineNode(
state: PDFOutlineState,
nodeId: string
): PDFOutlineState {
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
return nodes.map(node => {
if (node.id === nodeId) {
return { ...node, expanded: !node.expanded };
}
if (node.children.length > 0) {
return { ...node, children: updateNode(node.children) };
}
return node;
});
};
return { ...state, outline: updateNode(state.outline) };
}
function findOutlineNode(
nodes: PDFOutlineNode[],
id: string
): PDFOutlineNode | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findOutlineNode(node.children, id);
if (found) return found;
}
}
return null;
}
```
### 6.9 PDF Bookmarks
**File:** `web/src/reader/pdf/pdf-bookmarks.ts`
```typescript
// Custom bookmarks for PDF pages (saved in database)
// Procedural implementation (no OOP)
interface PDFBookmark {
id: string;
mediaItemId: string;
userId: string;
pageNumber: number;
title: string;
createdAt: string;
}
interface PDFBookmarksState {
mediaItemId: string;
bookmarks: PDFBookmark[];
}
function createPDFBookmarks(mediaItemId: string): PDFBookmarksState {
return {
mediaItemId,
bookmarks: []
};
}
async function loadPDFBookmarks(state: PDFBookmarksState): Promise<PDFBookmarksState> {
try {
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`);
if (!response.ok) throw new Error('Failed to load bookmarks');
const data = await response.json();
return { ...state, bookmarks: data.bookmarks || [] };
} catch (error) {
console.error('Failed to load bookmarks:', error);
return { ...state, bookmarks: [] };
}
}
async function addPDFBookmark(
state: PDFBookmarksState,
pageNumber: number,
title?: string
): Promise<PDFBookmarksState & { bookmark: PDFBookmark }> {
const bookmark: PDFBookmark = {
id: crypto.randomUUID(),
mediaItemId: state.mediaItemId,
userId: '',
pageNumber,
title: title || `Page ${pageNumber}`,
createdAt: new Date().toISOString()
};
try {
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page_number: pageNumber,
title: bookmark.title,
position: `pdf:page:${pageNumber}`
})
});
if (!response.ok) throw new Error('Failed to create bookmark');
const created = await response.json();
return {
...state,
bookmarks: [...state.bookmarks, created],
bookmark: created
};
} catch (error) {
console.error('Failed to add bookmark:', error);
throw error;
}
}
async function removePDFBookmark(
state: PDFBookmarksState,
bookmarkId: string
): Promise<PDFBookmarksState> {
try {
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to remove bookmark');
return {
...state,
bookmarks: state.bookmarks.filter(b => b.id !== bookmarkId)
};
} catch (error) {
console.error('Failed to remove bookmark:', error);
throw error;
}
}
function getPDFBookmarks(state: PDFBookmarksState): PDFBookmark[] {
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
}
function hasPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): boolean {
return state.bookmarks.some(b => b.pageNumber === pageNumber);
}
function getPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): PDFBookmark | null {
return state.bookmarks.find(b => b.pageNumber === pageNumber) || null;
}
```
### 6.10 PDF Clipboard
**File:** `web/src/reader/pdf/pdf-clipboard.ts`
```typescript
// Copy selected text to clipboard (plain text, preserve line breaks)
// Critical for technical textbooks with code examples
// Procedural implementation (no OOP)
function setupPDFClipboard(container: HTMLElement): void {
container.addEventListener('copy', (e) => {
handlePDFCopy(e);
});
}
function handlePDFCopy(event: ClipboardEvent): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (!selectedText) return;
const plainText = formatPDFPlainText(selectedText);
event.clipboardData?.setData('text/plain', plainText);
event.preventDefault();
showPDFCopyFeedback();
}
function formatPDFPlainText(text: string): string {
let formatted = text;
formatted = formatted.replace(/[ \t]+/g, ' ');
formatted = formatted.split('\n').map(line => line.trim()).join('\n');
formatted = formatted.replace(/\n{3,}/g, '\n\n');
return formatted;
}
async function copyPDFText(text: string): Promise<boolean> {
const formatted = formatPDFPlainText(text);
try {
await navigator.clipboard.writeText(formatted);
showPDFCopyFeedback();
return true;
} catch (error) {
console.error('Failed to copy text:', error);
const textarea = document.createElement('textarea');
textarea.value = formatted;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
const success = document.execCommand('copy');
if (success) {
showPDFCopyFeedback();
}
return success;
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
function showPDFCopyFeedback(): void {
const toast = document.createElement('div');
toast.className = 'pdf-copy-toast';
toast.textContent = 'Copied to clipboard';
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--accent);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.2s ease-out';
setTimeout(() => toast.remove(), 200);
}, 1500);
}
```
### 6.11 PDF Link Handler
**File:** `web/src/reader/pdf/pdf-links.ts`
```typescript
// Handle internal PDF links (cross-references, citations, TOC links)
// External links open in new tab
// Procedural implementation (no OOP)
interface PDFLink {
url: string;
pageNumber?: number;
bounds: { x: number; y: number; width: number; height: number };
}
interface PDFLinkHandlerState {
doc: PDFDocumentProxy | null;
container: HTMLElement;
onPageNavigate: (pageNumber: number) => void;
}
async function initializePDFLinkHandler(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void,
doc: PDFDocumentProxy
): Promise<PDFLinkHandlerState> {
const state: PDFLinkHandlerState = {
doc,
container,
onPageNavigate
};
await setupPDFLinks(state);
return state;
}
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
if (!state.doc) return;
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
const page = await state.doc.getPage(pageNum);
const annotations = await page.getAnnotations();
for (const annotation of annotations) {
if (annotation.subtype === 'Link') {
createPDFLinkElement(state, annotation, pageNum);
}
}
}
}
function createPDFLinkElement(
state: PDFLinkHandlerState,
annotation: any,
pageNumber: number
): void {
const pageElement = state.container.querySelector(`[data-page-number="${pageNumber}"]`);
if (!pageElement) return;
const link = document.createElement('a');
link.className = 'pdf-internal-link';
link.href = 'javascript:void(0)';
if (annotation.rect) {
const rect = annotation.rect;
link.style.position = 'absolute';
link.style.left = `${rect[0]}px`;
link.style.top = `${rect[1]}px`;
link.style.width = `${rect[2] - rect[0]}px`;
link.style.height = `${rect[3] - rect[1]}px`;
link.style.cursor = 'pointer';
}
link.addEventListener('click', (e) => {
e.preventDefault();
handlePDFLinkClick(state, annotation);
});
pageElement.appendChild(link);
}
async function handlePDFLinkClick(
state: PDFLinkHandlerState,
annotation: any
): Promise<void> {
if (!state.doc) return;
if (annotation.url) {
if (annotation.url.startsWith('http://') || annotation.url.startsWith('https://')) {
window.open(annotation.url, '_blank', 'noopener,noreferrer');
} else {
console.warn('Unhandled URL:', annotation.url);
}
} else if (annotation.dest) {
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
state.onPageNavigate(pageNumber);
}
}
async function resolvePDFLinkDestination(
state: PDFLinkHandlerState,
dest: string | any[]
): Promise<number> {
if (!state.doc) return 1;
try {
let explicitDest: any[];
if (typeof dest === 'string') {
const destObj = await state.doc.getDestination(dest);
if (!destObj) return 1;
explicitDest = destObj;
} else {
explicitDest = dest;
}
const ref = explicitDest[0];
if (typeof ref === 'object' && ref !== null) {
const pageIndex = await state.doc.getPageIndex(ref);
return pageIndex + 1;
} else if (typeof ref === 'number') {
return ref + 1;
}
return 1;
} catch (error) {
console.error('Failed to resolve link destination:', error);
return 1;
}
}
```
### 6.12 PDF Dual Page Spread View
**File:** `web/src/reader/pdf/pdf-dual-page.ts`
```typescript
// Dual page spread view for PDFs
// Procedural implementation (no OOP)
type DualPageMode = 'single' | 'dual';
interface PDFDualPageViewState {
currentMode: DualPageMode;
minViewportWidth: number;
}
function createPDFDualPageView(
container: HTMLElement,
onModeChange: (mode: DualPageMode) => void
): PDFDualPageViewState {
const state: PDFDualPageViewState = {
currentMode: 'single',
minViewportWidth: 1200
};
setupResponsiveDualPageToggle(container, state, onModeChange);
return state;
}
function setupResponsiveDualPageToggle(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void
): void {
const resizeObserver = new ResizeObserver(() => {
handleDualPageResize(container, state, onModeChange);
});
resizeObserver.observe(container);
}
function handleDualPageResize(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void
): PDFDualPageViewState {
const viewportWidth = window.innerWidth;
if (viewportWidth >= state.minViewportWidth && state.currentMode === 'single') {
if (!hasManualDualPageOverride()) {
return setDualPageMode(container, state, 'dual', false, onModeChange);
}
} else if (viewportWidth < state.minViewportWidth && state.currentMode === 'dual') {
return setDualPageMode(container, state, 'single', false, onModeChange);
}
return state;
}
function setDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
mode: DualPageMode,
manual: boolean,
onModeChange: (mode: DualPageMode) => void
): PDFDualPageViewState {
if (state.currentMode === mode) return state;
container.classList.remove('pdf-single-page', 'pdf-dual-page');
container.classList.add(mode === 'dual' ? 'pdf-dual-page' : 'pdf-single-page');
if (manual) {
setManualDualPageOverride(mode);
}
onModeChange(mode);
return { ...state, currentMode: mode };
}
function toggleDualPageMode(
container: HTMLElement,
state: PDFDualPageViewState,
onModeChange: (mode: DualPageMode) => void
): PDFDualPageViewState {
const newMode = state.currentMode === 'single' ? 'dual' : 'single';
return setDualPageMode(container, state, newMode, true, onModeChange);
}
function getDualPagePagePair(
state: PDFDualPageViewState,
currentPage: number,
totalPages: number
): { left?: number; right: number } {
if (state.currentMode === 'single') {
return { right: currentPage };
}
if (currentPage % 2 === 1) {
return {
left: currentPage > 1 ? currentPage - 1 : undefined,
right: currentPage
};
} else {
return {
left: currentPage,
right: currentPage < totalPages ? currentPage + 1 : currentPage
};
}
}
function hasManualDualPageOverride(): boolean {
return localStorage.getItem('pdf-dual-page-manual') === 'true';
}
function setManualDualPageOverride(mode: DualPageMode): void {
localStorage.setItem('pdf-dual-page-manual', 'true');
localStorage.setItem('pdf-dual-page-mode', mode);
}
function getDualPageStyles(): string {
return `
.pdf-dual-page .pdf-page-container {
display: inline-block;
vertical-align: top;
width: 50%;
}
.pdf-dual-page .pdf-scroll-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.pdf-single-page .pdf-page-container {
display: block;
width: 100%;
}
`;
}
```
### 6.13 PDF Mini-Map Navigation
**File:** `web/src/reader/pdf/pdf-minimap.ts`
```typescript
// Mini-map navigation for PDF pages
// Procedural implementation (no OOP)
interface PDFMiniMapState {
miniMap: HTMLElement;
currentPage: number;
totalPages: number;
thumbnails: Map<number, HTMLCanvasElement>;
onPageNavigate: (pageNumber: number) => void;
}
function createPDFMiniMap(
container: HTMLElement,
onPageNavigate: (pageNumber: number) => void
): PDFMiniMapState {
const miniMap = createMiniMapElement(container);
container.appendChild(miniMap);
return {
miniMap,
currentPage: 1,
totalPages: 0,
thumbnails: new Map(),
onPageNavigate
};
}
function createMiniMapElement(container: HTMLElement): HTMLElement {
const miniMap = document.createElement('div');
miniMap.className = 'pdf-minimap';
miniMap.innerHTML = `
<div class="pdf-minimap-header">Pages</div>
<div class="pdf-minimap-thumbnails"></div>
<div class="pdf-minimap-indicator"></div>
`;
const style = document.createElement('style');
style.textContent = getMiniMapStyles();
miniMap.appendChild(style);
return miniMap;
}
async function initializePDFMiniMap(
state: PDFMiniMapState,
totalPages: number,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
): Promise<PDFMiniMapState> {
const newState = { ...state, totalPages };
await generateMiniMapThumbnails(newState, renderThumbnail);
setupMiniMapEventListeners(newState);
return newState;
}
async function generateMiniMapThumbnails(
state: PDFMiniMapState,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
): Promise<void> {
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails') as HTMLElement;
container.innerHTML = '';
for (let page = 1; page <= state.totalPages; page++) {
try {
const thumbnail = await renderThumbnail(page);
thumbnail.className = 'pdf-minimap-thumbnail';
thumbnail.dataset.pageNumber = page.toString();
thumbnail.style.width = '80px';
thumbnail.style.height = 'auto';
thumbnail.style.cursor = 'pointer';
thumbnail.style.marginBottom = '4px';
container.appendChild(thumbnail);
state.thumbnails.set(page, thumbnail);
} catch (error) {
console.error(`Failed to generate thumbnail for page ${page}:`, error);
}
}
}
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails');
container?.addEventListener('click', (e) => {
const thumbnail = (e.target as HTMLElement).closest('.pdf-minimap-thumbnail') as HTMLElement;
if (thumbnail) {
const pageNumber = parseInt(thumbnail.dataset.pageNumber || '1');
state.onPageNavigate(pageNumber);
}
});
}
function updateMiniMapCurrentPage(state: PDFMiniMapState, pageNumber: number): PDFMiniMapState {
const indicator = state.miniMap.querySelector('.pdf-minimap-indicator') as HTMLElement;
const thumbnail = state.thumbnails.get(pageNumber);
if (thumbnail && indicator) {
const rect = thumbnail.getBoundingClientRect();
indicator.style.top = `${thumbnail.offsetTop}px`;
indicator.style.height = `${rect.height}px`;
}
state.thumbnails.forEach((thumb, page) => {
if (page === pageNumber) {
thumb.style.outline = '2px solid var(--accent)';
thumb.style.opacity = '1';
} else {
thumb.style.outline = 'none';
thumb.style.opacity = '0.7';
}
});
return { ...state, currentPage: pageNumber };
}
function showMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = 'block';
}
function hideMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = 'none';
}
function toggleMiniMap(state: PDFMiniMapState): void {
const isVisible = state.miniMap.style.display !== 'none';
state.miniMap.style.display = isVisible ? 'none' : 'block';
}
function getMiniMapStyles(): string {
return `
.pdf-minimap {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 100px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--text-secondary);
border-radius: 8px;
padding: 8px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.pdf-minimap-header {
font-size: 12px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.pdf-minimap-thumbnails {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pdf-minimap-thumbnail {
transition: outline 0.2s, opacity 0.2s;
border-radius: 2px;
}
.pdf-minimap-thumbnail:hover {
opacity: 1 !important;
outline: 1px solid var(--text-secondary) !important;
}
.pdf-minimap-indicator {
position: absolute;
left: 0;
right: 0;
border-left: 3px solid var(--accent);
pointer-events: none;
transition: top 0.3s ease-out;
}
`;
}
```
### 6.14 PDF Rotated Page Support
**File:** `web/src/reader/pdf/pdf-rotation.ts`
```typescript
// Handle rotated/landscape pages in PDFs
// Procedural implementation (no OOP)
interface PDFRotationState {
rotations: Map<number, number>;
}
function createPDFRotation(): PDFRotationState {
return {
rotations: new Map()
};
}
async function loadPDFPageRotations(
state: PDFRotationState,
doc: any
): Promise<PDFRotationState> {
const rotations = new Map<number, number>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const rotation = viewport.rotation;
if (rotation !== 0) {
rotations.set(pageNum, rotation);
}
}
return { ...state, rotations };
}
function getPDFPageRotation(state: PDFRotationState, pageNumber: number): number {
return state.rotations.get(pageNumber) || 0;
}
function hasPDFPageRotation(state: PDFRotationState, pageNumber: number): boolean {
return state.rotations.has(pageNumber);
}
function applyPDFRotation(
state: PDFRotationState,
canvas: HTMLCanvasElement,
pageNumber: number
): void {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-canvas.width / 2, -canvas.height / 2);
ctx.restore();
}
function getPDFAdjustedViewport(
state: PDFRotationState,
pageNumber: number,
viewport: any
): any {
const rotation = getPDFPageRotation(state, pageNumber);
if (rotation === 0 || rotation === 180) {
return viewport;
}
return {
...viewport,
width: viewport.height,
height: viewport.width
};
}
```
### 6.15 PDF Variable Page Sizes
**File:** `web/src/reader/pdf/pdf-page-sizes.ts`
```typescript
// Handle PDFs with variable page sizes
// Procedural implementation (no OOP)
interface PageInfo {
pageNumber: number;
width: number;
height: number;
rotation: number;
}
interface PDFPageSizesState {
pageSizes: Map<number, PageInfo>;
defaultSize: { width: number; height: number };
}
function createPDFPageSizes(): PDFPageSizesState {
return {
pageSizes: new Map(),
defaultSize: { width: 595, height: 842 }
};
}
async function loadPDFPageSizes(
state: PDFPageSizesState,
doc: any
): Promise<PDFPageSizesState> {
const pageSizes = new Map<number, PageInfo>();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const pageInfo: PageInfo = {
pageNumber: pageNum,
width: viewport.width,
height: viewport.height,
rotation: viewport.rotation
};
pageSizes.set(pageNum, pageInfo);
}
return { ...state, pageSizes };
}
function getPDFPageSize(
state: PDFPageSizesState,
pageNumber: number
): PageInfo | null {
return state.pageSizes.get(pageNumber) || null;
}
function isPDFPageLandscape(
state: PDFPageSizesState,
pageNumber: number
): boolean {
const size = getPDFPageSize(state, pageNumber);
if (!size) return false;
const effectiveWidth = size.rotation === 90 || size.rotation === 270
? size.height
: size.width;
const effectiveHeight = size.rotation === 90 || size.rotation === 270
? size.width
: size.height;
return effectiveWidth > effectiveHeight;
}
function getPDFCommonSize(state: PDFPageSizesState): { width: number; height: number } {
if (state.pageSizes.size === 0) {
return state.defaultSize;
}
const sizeGroups: Map<string, { width: number; height: number; count: number }> = new Map();
state.pageSizes.forEach((size) => {
const key = getPageSizeKey(size.width, size.height);
const existing = sizeGroups.get(key);
if (existing) {
existing.count++;
} else {
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
}
});
let mostCommon = state.defaultSize;
let maxCount = 0;
sizeGroups.forEach((size) => {
if (size.count > maxCount) {
maxCount = size.count;
mostCommon = { width: size.width, height: size.height };
}
});
return mostCommon;
}
function getPageSizeKey(width: number, height: number): string {
const w = Math.round(width / 10) * 10;
const h = Math.round(height / 10) * 10;
return `${w}x${h}`;
}
```
---
## 7. Panel Detection Implementation
### 7.1 Grid-Based Detection (Primary)
**File:** `web/src/reader/comic/panel-detector.ts`
```typescript
// Grid-based panel detection (fast, lightweight)
interface GridConfig {
rows: number;
cols: number;
}
function detectPanelsGrid(
imageData: ImageData,
config: GridConfig = { rows: 3, cols: 3 }
): Panel[] {
const panels: Panel[] = [];
const cellWidth = imageData.width / config.cols;
const cellHeight = imageData.height / config.rows;
for (let y = 0; y < config.rows; y++) {
for (let x = 0; x < config.cols; x++) {
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
if (!isEmpty(cell)) {
panels.push({
id: `panel-${panels.length}`,
x: (x / config.cols) * 100,
y: (y / config.rows) * 100,
width: (1 / config.cols) * 100,
height: (1 / config.rows) * 100,
reading_order: panels.length
});
}
}
}
return mergeAdjacentPanels(panels);
}
function isEmpty(cellData: ImageData): boolean {
// Simple edge detection to find empty space
// Count white/transparent pixels
let emptyPixels = 0;
const totalPixels = cellData.width * cellData.height;
const threshold = 0.95; // 95% empty = empty cell
for (let i = 0; i < cellData.data.length; i += 4) {
const r = cellData.data[i];
const g = cellData.data[i + 1];
const b = cellData.data[i + 2];
const a = cellData.data[i + 3];
// Consider white or transparent as empty
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
emptyPixels++;
}
}
return (emptyPixels / totalPixels) > threshold;
}
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
// Merge panels that are next to each other
// Simplified algorithm - can be enhanced
const merged: Panel[] = [];
const used = new Set<number>();
for (let i = 0; i < panels.length; i++) {
if (used.has(i)) continue;
let current = { ...panels[i] };
used.add(i);
// Look for adjacent panels
for (let j = i + 1; j < panels.length; j++) {
if (used.has(j)) continue;
if (isAdjacent(current, panels[j])) {
current = mergePanels(current, panels[j]);
used.add(j);
}
}
merged.push(current);
}
return merged;
}
```
### 7.2 ML-Based Detection (Enhancement)
**File:** `web/src/reader/comic/panel-ml-detector.ts`
```typescript
// ML-based panel detection (optional, lazy-loaded)
// Uses TensorFlow.js for accurate panel detection
let modelLoaded = false;
let panelModel: any = null;
async function loadMLModel(): Promise<void> {
if (modelLoaded) return;
try {
// Lazy-load TensorFlow.js
await import('@tensorflow/tfjs');
// Load pre-trained model for panel detection
// Model should be small (~2MB) and fast
panelModel = await loadModel('/static/models/panel-detection/model.json');
modelLoaded = true;
} catch (error) {
console.error('Failed to load ML model:', error);
// Fall back to grid-based detection
}
}
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
if (!modelLoaded) {
await loadMLModel();
}
if (!panelModel) {
// Fall back to grid-based
return detectPanelsGrid(imageData);
}
// Run ML model
const predictions = await panelModel.detect(imageData);
// Convert predictions to Panel format
return predictions.map((pred: any, index: number) => ({
id: `ml-panel-${index}`,
x: pred.bbox.x * 100,
y: pred.bbox.y * 100,
width: pred.bbox.width * 100,
height: pred.bbox.height * 100,
reading_order: index
}));
}
```
### 7.3 Manual Override
**File:** `web/src/reader/comic/panel-editor.ts`
```typescript
// Manual panel editor for admins/power users
function openPanelEditor(pageNumber: number): void {
const modal = document.getElementById('panel-editor-modal');
modal?.classList.remove('hidden');
// Load page image
const canvas = document.getElementById('panel-editor-canvas') as HTMLCanvasElement;
const ctx = canvas?.getContext('2d');
// Load image and draw to canvas
loadImageForPage(pageNumber).then((image) => {
canvas!.width = image.width;
canvas!.height = image.height;
ctx?.drawImage(image, 0, 0);
// Allow user to draw panels
enablePanelDrawing(canvas!);
});
}
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
let isDrawing = false;
let startX = 0;
let startY = 0;
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
startX = e.offsetX;
startY = e.offsetY;
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
// Draw selection rectangle
const ctx = canvas.getContext('2d');
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
});
canvas.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
// Save panel
const panel: Panel = {
id: `manual-${Date.now()}`,
x: (startX / canvas.width) * 100,
y: (startY / canvas.height) * 100,
width: ((e.offsetX - startX) / canvas.width) * 100,
height: ((e.offsetY - startY) / canvas.height) * 100,
reading_order: 0 // Will be set by server
};
saveManualPanel(panel);
});
}
async function saveManualPanel(panel: Panel): Promise<void> {
const mediaItemId = document.body.dataset.mediaItemId;
const pageNumber = getCurrentPageNumber();
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
detection_method: 'manual',
panels: [panel]
});
// Reload with new panels
loadPage(pageNumber);
}
```
---
## 8. Lazy Loading & Caching
### 8.1 Page Cache (5-Page Ahead)
**File:** `web/src/reader/comic/page-cache.ts`
```typescript
// Lazy-loading page cache with 5-page ahead prefetch
// Lazy-loading page cache with 5-page ahead prefetch
// Procedural implementation (no OOP)
interface PageCacheState {
cache: Map<number, HTMLImageElement>;
loading: Set<number>;
maxAhead: number;
mediaItemId: string;
}
function createPageCache(mediaItemId: string): PageCacheState {
return {
cache: new Map(),
loading: new Set(),
maxAhead: 5,
mediaItemId
};
}
async function getCachedPage(
state: PageCacheState,
pageNumber: number
): Promise<PageCacheState & { page: HTMLImageElement }> {
if (state.cache.has(pageNumber)) {
return { ...state, page: state.cache.get(pageNumber)! };
}
if (state.loading.has(pageNumber)) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (state.cache.has(pageNumber)) {
clearInterval(checkInterval);
resolve({ ...state, page: state.cache.get(pageNumber)! });
}
}, 100);
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
}
const newLoading = new Set(state.loading);
newLoading.add(pageNumber);
const img = await loadComicPage(state, pageNumber);
const newCache = new Map(state.cache);
newCache.set(pageNumber, img);
newLoading.delete(pageNumber);
const newState = { ...state, cache: newCache, loading: newLoading };
prefetchPages(newState, pageNumber + 1);
cleanupPageCache(newState, pageNumber);
return { ...newState, page: img };
}
async function loadComicPage(
state: PageCacheState,
pageNumber: number
): Promise<HTMLImageElement> {
const token = localStorage.getItem('token');
const response = await fetch(
`/api/readers/${state.mediaItemId}/pages/${pageNumber}`,
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (!response.ok) {
throw new Error(`Failed to load page ${pageNumber}`);
}
const blob = await response.blob();
const img = new Image();
img.src = URL.createObjectURL(blob);
await new Promise((resolve) => {
img.onload = resolve;
});
return img;
}
function prefetchPages(state: PageCacheState, startPage: number): void {
for (let i = startPage; i < startPage + state.maxAhead; i++) {
if (!state.cache.has(i) && !state.loading.has(i)) {
loadComicPage(state, i).then((img) => {
state.cache.set(i, img);
});
}
}
}
function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState {
const keepPages = 10;
const newCache = new Map(state.cache);
for (const [page] of state.cache) {
if (page < currentPage - keepPages) {
newCache.delete(page);
}
}
return { ...state, cache: newCache };
}
```
---
## 9. Offline Support (PWA)
### 9.1 Service Worker
**File:** `web/static/service-worker.js` (new file)
```javascript
// Service worker for offline reading
const CACHE_NAME = 'bookhoard-reader-v1';
const OFFLINE_CACHE = 'bookhoard-offline';
// Cache dictionary data for offline use
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.addAll([
'/static/dictionary/en-US.json',
'/static/dictionary/en-GB.json'
]);
})
);
});
// Cache reader pages
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache reader pages
if (url.pathname.startsWith('/api/readers/') && url.pathname.includes('/pages/')) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((response) => {
if (response) {
return response;
}
// Fetch and cache
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
// Cache dictionary lookups
if (url.pathname.startsWith('/api/readers/dictionary/')) {
event.respondWith(
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache dictionary responses
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
});
// Cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) {
return caches.delete(cacheName);
}
})
);
})
);
});
```
### 9.2 PWA Manifest
**File:** `web/static/manifest.json` (new file)
```json
{
"name": "Bookhoard Reader",
"short_name": "Reader",
"description": "Offline-capable ebook and comic reader",
"start_url": "/dashboard",
"display": "fullscreen",
"background_color": "#1a1b26",
"theme_color": "#1a1b26",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"offline_enabled": true
}
```
### 9.3 Register Service Worker
**File:** `web/src/reader/offline-manager.ts` (new file)
```typescript
// Offline manager for PWA functionality
export function registerServiceWorker(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/service-worker.js')
.then((registration) => {
console.log('Service worker registered:', registration);
})
.catch((error) => {
console.error('Service worker registration failed:', error);
});
}
}
export function checkOnlineStatus(): boolean {
if (typeof navigator !== 'undefined' && navigator.onLine) {
return true;
}
return false;
}
// Listen for online/offline events
window.addEventListener('online', () => {
showToast('Back online', 'success');
// Sync any pending changes
syncPendingChanges();
});
window.addEventListener('offline', () => {
showToast('You are offline. Some features may be limited.', 'warning');
});
```
---
## 10. Dictionary Implementation
### 10.1 Dictionary Data
**File:** `web/static/dictionary/en-US.json` (new file)
Compressed dictionary data with common words. Format:
```json
{
"word": {
"definition": "A single distinct meaningful element of speech or writing",
"part_of_speech": "noun",
"example": "The words 'the', 'and', and 'word' are examples of words.",
"etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort."
}
}
```
Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage.
### 10.2 Dictionary Popup
**File:** `web/src/reader/ebook/dictionary-popup.ts`
```typescript
// Dictionary lookup popup for ebooks
import { lookupWord } from "./api";
function showDictionaryPopup(word: string, position: { x: number; y: number }): void {
// Remove existing popup
const existing = document.getElementById('dictionary-popup');
existing?.remove();
// Create popup
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);
// Look up word
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((error) => {
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
});
// Close on click outside
setTimeout(() => {
document.addEventListener('click', function closePopup(e: MouseEvent) {
if (!popup.contains(e.target as Node)) {
popup.remove();
document.removeEventListener('click', closePopup);
}
});
}, 100);
}
// Text selection handler for ebooks
function handleTextSelection(): void {
document.addEventListener('mouseup', () => {
const selection = window.getSelection();
const selectedText = selection?.toString().trim();
if (selectedText && selectedText.split(' ').length === 1) {
// Single word selected - show dictionary
const range = selection?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect) {
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
}
}
});
}
```
---
## 11. Reading Statistics Integration
### 11.1 Track Reading Speed
**File:** `web/src/reader/reading-speed-tracker.ts`
```typescript
// Track reading speed and update database
// Reading speed tracker
// Procedural implementation (no OOP)
interface ReadingSpeedTrackerState {
startTime: number | null;
pagesRead: number;
wordsRead: number;
lastSync: number;
mediaItemId: string;
}
function createReadingSpeedTracker(mediaItemId: string): ReadingSpeedTrackerState {
return {
startTime: null,
pagesRead: 0,
wordsRead: 0,
lastSync: Date.now(),
mediaItemId
};
}
function startReadingSession(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState {
return {
...state,
startTime: Date.now(),
pagesRead: 0,
wordsRead: 0
};
}
function recordPageTurn(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState {
if (!state.startTime) return state;
const newPagesRead = state.pagesRead + 1;
const now = Date.now();
if (newPagesRead % 5 === 0 || (now - state.lastSync) > 5 * 60 * 1000) {
syncReadingSpeed({ ...state, pagesRead: newPagesRead });
return { ...state, pagesRead: newPagesRead, lastSync: now };
}
return { ...state, pagesRead: newPagesRead };
}
function recordWordsRead(
state: ReadingSpeedTrackerState,
wordCount: number
): ReadingSpeedTrackerState {
return {
...state,
wordsRead: state.wordsRead + wordCount
};
}
async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise<void> {
if (!state.startTime) return;
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
const pagesPerMinute = state.pagesRead / minutesElapsed;
const wordsPerMinute = state.wordsRead / minutesElapsed;
await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed
});
}
```
---
## 12. UI/UX Implementation
### 12.1 Reader Template (SSR)
**File:** `templates/reader.templ` (new file)
```go
package templates
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
x-data="readerShell"
x-init="initReader()"
class="theme-{ user.Theme }"
data-media-item-id={ metadata.media_item_id }
data-media-type={ metadata.library_type }
data-total-pages={ metadata.total_pages }
>
@ReaderChrome(user, metadata, progress)
<main id="reader-content" class="w-full h-full">
<!-- Content loaded by JavaScript based on media type -->
</main>
@ReaderSettingsPanel()
@ReaderTOCPanel(metadata)
@DictionaryPopup()
</body>
</html>
}
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300">
<!-- Top bar -->
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<a href="/media-items/{ metadata.media_item_id }" class="text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.title }</h1>
<button
data-action="open-settings"
class="p-2 rounded-lg hover:bg-gray-700"
title="Settings"
>
</button>
</div>
</div>
<!-- Bottom bar -->
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<div id="progress-display" data-progress-mode="pages">
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
</div>
<div class="flex items-center gap-4">
<button data-action="toggle-toc" title="Table of Contents">📖</button>
<button data-action="add-bookmark" title="Bookmark">🏷</button>
<button data-action="add-note" title="Note">📝</button>
</div>
</div>
</div>
</div>
}
templ ReaderSettingsPanel() {
<div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<!-- Display settings -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Display</h3>
<label class="block mb-2">
Chrome Behavior
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
<option value="auto-hide">Auto Hide</option>
<option value="always-visible">Always Visible</option>
<option value="hide-on-scroll">Hide on Scroll</option>
</select>
</label>
<label class="block mb-2">
Progress Mode
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
<option value="pages">Pages</option>
<option value="chapter">Chapter</option>
<option value="percentage">Percentage</option>
<option value="time-left">Time Left</option>
</select>
</label>
</div>
<!-- Typography (ebooks only) -->
<div class="mb-6" data-visible-for="ebook">
<h3 class="font-semibold mb-2">Typography</h3>
<label class="block mb-2">
Reading Font
<select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
<option value="literata">Literata (Default - Designed for ebooks)</option>
<option value="crimson">Crimson Text (Screen-optimized)</option>
<option value="source-serif">Source Serif 4 (Adobe quality)</option>
<option value="eb-garamond">EB Garamond (Classic)</option>
<option value="libertinus">Libertinus Serif (Technical)</option>
<option value="noto-serif">Noto Serif (All languages)</option>
<option value="charis-sil">Charis SIL (Multilingual)</option>
<option value="ibm-plex">IBM Plex Serif (Modern)</option>
</select>
<p class="text-xs mt-1" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
</label>
<label class="block mb-2">
Font Size
<input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
</label>
<label class="block mb-2">
Line Height
<input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
</label>
</div>
<!-- Navigation -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Navigation</h3>
<label class="flex items-center mb-2">
<input type="checkbox" name="panel_zoom_enabled" class="mr-2"/>
Panel Zoom (Comics/Manga)
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="double_page_spread" class="mr-2"/>
Double Page Spread (Comics/Manga)
</label>
</div>
<button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
}
templ ReaderTOCPanel(metadata ReaderMetadata) {
<div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
<div id="toc-content">
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
for _, chapter := range metadata.chapter_metadata.Chapters {
<a
href="#"
data-chapter-id={ chapter.ID }
class="block py-2 hover:bg-gray-700 rounded px-2"
>
{ chapter.Title }
</a>
}
} else {
<p class="text-sm">No chapters available</p>
}
</div>
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
Close
</button>
</div>
</div>
}
templ DictionaryPopup() {
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
}
```
---
## 13. Integration Tests
### 13.1 Test Setup
**File:** `cmd/server/tests/reader_test.go` (new file)
Follow existing test patterns from `media_test.go` and `auth_test.go`:
```go
package tests
import (
"bookhoard/internal/database"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
func TestReaderEndpoints(t *testing.T) {
setup := setupTestServer(t)
defer teardownTestServer(t, setup)
// Create test user and media item
ctx := setup.ctx
queries := setup.queries
user := createTestUser(t, ctx, queries)
admin := createTestAdmin(t, ctx, queries)
mediaItem := createTestMediaItem(t, ctx, queries, user.ID)
accessToken := loginTestUser(t, setup, user.Email, "password123")
adminToken := loginTestUser(t, setup, admin.Email, "admin123")
t.Run("Get Reader Page - User", func(t *testing.T) {
// Test SSR reader page
// Test that user can access their own media items
})
t.Run("Get Reader Page - No User", func(t *testing.T) {
// Test 401 without authentication
})
t.Run("Get Page - Lazy Loading", func(t *testing.T) {
// Test page lazy loading endpoint
})
t.Run("Get Chapters", func(t *testing.T) {
// Test chapter metadata endpoint
})
t.Run("Get Panels - Grid Detection", func(t *testing.T) {
// Test panel detection endpoint
})
t.Run("Update Panels - Manual Override", func(t *testing.T) {
// Test manual panel override (admin only)
})
t.Run("Reading Speed", func(t *testing.T) {
// Test reading speed tracking
})
t.Run("Dictionary Lookup", func(t *testing.T) {
// Test dictionary endpoint
})
t.Run("Settings Management", func(t *testing.T) {
// Test settings CRUD
})
t.Run("Offline Support - Service Worker", func(t *testing.T) {
// Test service worker registration
// Test offline caching
})
}
```
---
## 14. Phased Implementation
### Phase 1: Infrastructure & Basic Reader (Week 1-2)
- [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings)
- [ ] Create reader service layer (`internal/services/reader_service.go`)
- [ ] Create reader handlers (`internal/handlers/reader.go`)
- [ ] Register reader routes (`internal/router/reader.go`)
- [ ] Create reader template (`templates/reader.templ`)
- [ ] Implement reader shell infrastructure
- [ ] Implement settings manager (DB + localStorage)
- [ ] Implement progress indicator (KOReader-style)
- [ ] Create basic ebook reader (HTML rendering)
- [ ] Create basic comic reader (image display)
- [ ] Integration tests for infrastructure
### Phase 2: Comic/Manga Features (Week 3-4)
- [ ] Implement grid-based panel detection
- [ ] Implement panel zoom with animations
- [ ] Implement page cache (5-page ahead)
- [ ] Implement manga RTL navigator
- [ ] Implement manga vertical scroll mode
- [ ] Implement chapter detection for all media types
- [ ] Integration tests for comic/manga features
### Phase 3: Advanced Features (Week 5-6)
- [ ] Implement ML-based panel detection (optional enhancement)
- [ ] Implement manual panel editor
- [ ] Implement dictionary popup for ebooks
- [ ] Implement offline dictionary cache
- [ ] Implement reading speed tracker
- [ ] Implement annotation manager (highlights, notes, bookmarks)
- [ ] Integration tests for advanced features
### Phase 4: Offline Support (Week 7)
- [ ] Create service worker
- [ ] Implement page caching for offline reading
- [ ] Implement dictionary offline caching
- [ ] Create PWA manifest
- [ ] Implement online/offline detection
- [ ] Integration tests for offline support
### Phase 5: Polish & Testing (Week 8)
- [ ] Performance optimization
- [ ] Cross-browser testing
- [ ] Mobile responsiveness testing
- [ ] Accessibility testing
- [ ] Security audit
- [ ] Documentation (user guides, API docs)
- [ ] End-to-end testing
---
## 15. Code Reuse Strategy
### 15.1 Reuse Existing Systems
**WebSocket Sync (`internal/sync/websocket.go`)**
- Reuse for real-time progress updates
- Reuse for annotation sync
- Reuse for bookmark sync
**Progress Tracking (`internal/sync/progress.go`)**
- Reuse EPUB CFI navigation logic
- Reuse percentage calculation
- Reuse chapter-relative page calculation
**Format Handling (`internal/sync/format.go`)**
- Reuse format detection logic
- Reuse normalization functions
**Annotation Tables (notes, highlights)**
- Reuse existing database schema
- Reuse existing API endpoints
- Build UI on top of existing data
**Theme System (11 dark themes)**
- Reuse existing theme CSS variables
- Apply theme to reader UI
- Ensure consistency across app
**Auth & User Management**
- Reuse JWT middleware
- Reuse user preferences
- Reuse role-based access control
### 15.2 Surgical Code Edits
**Avoid:**
- ❌ Duplicating existing logic
- ❌ Rewriting working code
- ❌ Creating parallel systems
**Do:**
- ✅ Extend existing types where appropriate
- ✅ Add new methods to existing services
- ✅ Follow existing patterns and conventions
- ✅ Use existing test helpers
**Example - Extending sync/format.go:**
```go
// EXISTING CODE in sync/format.go
func CalculateProgress(currentPage, totalPages int) float64 {
if totalPages == 0 {
return 0
}
return float64(currentPage) / float64(totalPages) * 100
}
// NEW CODE - Add chapter-relative progress
func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) (int, int) {
chapterPage := currentPage - chapterStartPage + 1
return chapterPage, chapterPages
}
```
---
## 16. Bruno API Tests
**File:** `bruno/reader/reader.bru` (new folder)
Create Bruno OpenCollection YAML requests for:
```
bruno/reader/
├── get-reader-page.bru
├── get-page.bru
├── get-chapters.bru
├── get-panels.bru
├── update-panels.bru
├── get-reading-speed.bru
├── update-reading-speed.bru
├── lookup-word.bru
├── get-settings.bru
└── update-settings.bru
```
Follow existing Bruno patterns from `bruno/media/` and `bruno/auth/`.
---
## 17. Documentation
### 17.1 User Documentation
**File:** `docs/user/reader.md` (new file)
Comprehensive user guide covering:
- How to open the reader
- Navigation controls
- Progress indicator modes
- Settings options
- Panel zoom for comics/manga
- Dictionary lookup
- Bookmarks, highlights, notes
- Offline reading
- Keyboard shortcuts
### 17.2 Developer Documentation
**File:** `docs/contributing/reader-architecture.md` (new file)
Technical documentation covering:
- Reader architecture overview
- Component structure
- Data flow diagrams
- Panel detection algorithms
- Caching strategy
- Offline support implementation
- Testing strategy
---
## 18. Success Criteria
### 18.1 Functional Requirements
- ✅ User can read ebooks (EPUB) with adjustable typography
- ✅ User can read comics (CBZ/CBR/PDF) with panel zoom
- ✅ User can read manga with RTL and vertical scroll modes
- ✅ Progress syncs across devices via WebSocket
- ✅ User can create bookmarks, highlights, notes
- ✅ User can look up words in dictionary (offline)
- ✅ Reader works offline for cached content
- ✅ Settings persist across devices (DB) and browsers (localStorage)
- ✅ 8 bundled libre reading fonts (no network requests)
- ✅ UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes
### 18.2 Performance Requirements
- ⚡ Initial page load: < 2 seconds
- ⚡ Page turn (comics): < 500ms with 5-page cache
- ⚡ Panel zoom animation: 300ms smooth
- ⚡ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached)
- ⚡ Offline cache hit: < 100ms
### 18.3 Quality Requirements
- ✅ Zero TypeScript errors
- ✅ All integration tests passing
- ✅ Zero known security vulnerabilities
- ✅ Mobile-responsive (320px - 4K)
- ✅ Keyboard accessible
- ✅ WCAG 2.1 AA compliant
---
## 19. Future Enhancements (Out of Scope for Initial Implementation)
- TTS (Text-to-Speech) - user excluded
- Advanced ML panel detection with custom model
- Social features (share highlights, see friends' progress)
- Advanced annotations (draw on pages, voice notes)
- PDF form filling
- EPUB audio/video media overlays
- Advanced manga panel navigation (auto-detect panel order)
- Reading goals and challenges
- Social reading (book clubs, shared annotations)
---
## Conclusion
This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The **universal reader architecture with pluggable parsers** provides the best balance of code reuse, maintainability, and extensibility.
**Key principles:**
- **Universal reader**: One rendering engine for all reflowable ebooks (EPUB, FB2, TXT, HTML, MOBI, AZW3, DOCX, RTF)
- **Common Intermediate Format (CIF)**: Standardized HTML structure that all parsers produce
- **Hybrid parsing**: Client-side for simple formats (~500 KB), server-side for complex formats (no 182 MB Calibre dependency)
- **Procedural TypeScript**: Functions, not classes (per PROJECT_GUIDELINES.md)
- **Surgical code reuse**: Extend existing systems (WebSocket sync, progress tracking, annotations)
- **Progressive enhancement**: SSR-first with TypeScript enhancements
- **Privacy-first**: Per-user settings with localStorage fallback
- **Offline-capable**: PWA with service worker
- **Libre fonts only**: 8 bundled open-source reading fonts
- **Hybrid theming**: 11 themes for UI/comics, 5 reading-optimized themes for ebook text
**Supported Formats:**
| Format | Parser Location | Dependency Size | Status |
|--------|-----------------|-----------------|--------|
| **EPUB 2/3** | Client (TypeScript) | 0 KB (JSZip) | ✅ Planned |
| **FB2** | Client (TypeScript) | 0 KB (XML) | ✅ Planned |
| **TXT** | Client (TypeScript) | 0 KB | ✅ Planned |
| **HTML** | Client (TypeScript) | 0 KB | ✅ Planned |
| **MOBI** | Server (Go) | ~100 KB | ✅ Planned |
| **AZW3** | Server (Go) | ~50 KB | ✅ Planned |
| **DOCX** | Server (Go) | ~200 KB (mammoth) | ✅ Planned |
| **RTF** | Server (Go) | ~50 KB | ✅ Planned |
| **PDF** | Client (pdf.js) | ~500 KB | ✅ Planned |
| **Comics** | Client (canvas) | 0 KB | ✅ Planned |
| **Manga** | Client (extends comics) | 0 KB | ✅ Planned |
**Total client-side dependencies: ~1 MB (vs. 182 MB for Calibre)**
**Key design decisions:**
- **Architecture**: Universal reader + parser pipeline (not separate readers)
- **Parsing**: Hybrid (client for simple, server for complex)
- **Code style**: Procedural TypeScript (no OOP per guidelines)
- **Fonts**: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only
- **Theming**: Hybrid - 11 themes for UI, 5 reading-optimized themes for text
- **Typography**: Optimized for extended reading (Literata default)
**Estimated timeline:** 8 weeks for full implementation
**Next steps:**
1. Review and approve this plan
2. Begin Phase 1: Infrastructure & Basic Reader
3. Create database schema (add pdf_bookmarks table)
4. Implement parser manager and CIF types
5. Build universal reader shell (procedural style)
6. Implement parsers (start with EPUB, TXT - simplest first)
7. Add server-side parsers for complex formats (MOBI, AZW3, DOCX)
---
*Plan created: 2025*
*Last updated: 2025*
*Major revision: Universal reader architecture + procedural TypeScript*