Files
bookhoard/READER_IMPLEMENTATION_PLAN.md
T
john-okeefe 925228f7cf docs: comprehensive reader implementation plan updates
Major changes:
- Convert all 22 OOP classes to procedural TypeScript functions
- Add complete PDF reader implementation (15 subsections, 6.1-6.15)
- Add 8 technical textbook features for PDFs (TOC, bookmarks, clipboard,
  dual-page, mini-map, rotation, variable page sizes)
- Remove duplicate sections and fix section numbering (1-19 sequential)
- Add 7 new comic/manga features (7.4-7.10):
  * Page order presets with auto-detect and user override
  * Extended keyboard shortcuts (Space, PageUp/Down, +/-, B, F, 1-9)
  * Page slider/scrubber for quick navigation
  * Touch gesture controls (swipe, pinch-to-zoom, tap, double-tap)
  * Panel gap controls with adjustable spacing and borders
  * Background color options (black/white/gray/sepia)
  * Chapter markers with visual boundaries

Architecture improvements:
- Universal reader + pluggable parser pipeline (all ebooks → CIF)
- Client-side parsers: EPUB, FB2, TXT, HTML
- Server-side parsers: MOBI, AZW3, DOCX, RTF (Go backend)
- ~500 KB total dependencies vs 182 MB Calibre

Database schema updates:
- Rename pdf_bookmarks to media_bookmarks
- Add chapter_number and cfi_position fields for all media types
- Unified bookmarks for ebooks, comics, manga, and PDFs

Preserved features:
- 8 bundled libre reading fonts with complete setup scripts
- 5 reading-optimized themes (light, sepia, dark, night, high-contrast)
- Hybrid theming strategy (11 themes for UI, 5 for ebook text)
- Panel detection (grid-based + ML + manual override)
- All 4 media types: ebook, comic, manga, PDF

All code follows PROJECT_GUIDELINES.md:
- No OOP (no classes, no this-capture)
- Procedural/imperative style
- Backend handles all position calculations (CFI, character offsets, etc.)
- Frontend receives positions from backend, no duplication
2026-04-02 16:58:45 -04:00

240 KiB
Raw Blame History

📖 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:

// ❌ 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

-- 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 media_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,
  chapter_number INTEGER,
  cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position
  title VARCHAR(255) NOT NULL,
  position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0))
);

CREATE INDEX idx_media_bookmarks_media ON media_bookmarks(media_item_id);
CREATE INDEX idx_media_bookmarks_user ON media_bookmarks(user_id);

2.2 Alter Existing Tables

-- 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)

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:

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:

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)

// ============================================================
// 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;
  media_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

// 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)

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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)

#!/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:

# 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:

# 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:

# 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:

# 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:

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>
        ...
}

If you don't want to bundle fonts (slower initial load, privacy concerns):

<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):

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:

// 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:

// 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)

/* 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)

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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;
}

File: web/src/reader/pdf/pdf-search.ts

// 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

// 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

// 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

// 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

// Custom bookmarks for PDF pages (saved in database)
// Procedural implementation (no OOP)

interface MediaBookmark {
  id: string;
  mediaItemId: string;
  userId: string;
  pageNumber: number;
  title: string;
  createdAt: string;
}

interface MediaBookmarksState {
  mediaItemId: string;
  bookmarks: MediaBookmark[];
}

function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
  return {
    mediaItemId,
    bookmarks: []
  };
}

async function loadMediaBookmarks(state: MediaBookmarksState): Promise<MediaBookmarksState> {
  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 addMediaBookmark(
  state: MediaBookmarksState,
  pageNumber: number,
  title?: string
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
  const bookmark: MediaBookmark = {
    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 removeMediaBookmark(
  state: MediaBookmarksState,
  bookmarkId: string
): Promise<MediaBookmarksState> {
  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 getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
  return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
}

function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean {
  return state.bookmarks.some(b => b.pageNumber === pageNumber);
}

function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | null {
  return state.bookmarks.find(b => b.pageNumber === pageNumber) || null;
}

6.10 PDF Clipboard

File: web/src/reader/pdf/pdf-clipboard.ts

// 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);
}

File: web/src/reader/pdf/pdf-links.ts

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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);
}

7.4 Page Order Presets

File: web/src/reader/comic/page-order.ts

// Page order presets for manga/comics
// Auto-detect Japanese vs Western reading order
// Allow user override in case detection is wrong
// Procedural implementation (no OOP)

type PageOrderMode = 'auto' | 'japanese' | 'western';

interface PageOrderConfig {
  mode: PageOrderMode;
  detectedOrder: PageOrderMode;
  userOverride: boolean;
}

interface PageOrderState {
  config: PageOrderConfig;
  totalPages: number;
}

// Detect page order based on filename patterns
function detectPageOrder(pageNames: string[]): PageOrderMode {
  if (pageNames.length < 2) return 'western';

  const firstPage = pageNames[0].toLowerCase();
  const lastPage = pageNames[pageNames.length - 1].toLowerCase();

  const hasFrontCover = /cover|front|001/.test(firstPage);
  const hasBackCover = /back|end|最后的/.test(lastPage);

  if (hasFrontCover && !hasBackCover) {
    return 'western';
  }
  if (hasBackCover && !hasFrontCover) {
    return 'japanese';
  }

  const chapterMatches = pageNames.filter(n => /ch-\d+|chapter/i.test(n));
  if (chapterMatches.length > 0) {
    const firstChapter = chapterMatches[0];
    const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || '0');
    return pageNum > 0 ? 'western' : 'japanese';
  }

  return 'western';
}

function createPageOrderState(totalPages: number, pageNames: string[]): PageOrderState {
  const detectedOrder = detectPageOrder(pageNames);

  return {
    config: {
      mode: 'auto',
      detectedOrder,
      userOverride: false
    },
    totalPages
  };
}

function setPageOrderMode(state: PageOrderState, mode: PageOrderMode): PageOrderState {
  return {
    ...state,
    config: {
      ...state.config,
      mode,
      userOverride: mode !== 'auto'
    }
  };
}

function getPageOrder(state: PageOrderState): PageOrderMode {
  if (state.config.mode === 'auto') {
    return state.config.detectedOrder;
  }
  return state.config.mode;
}

function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] {
  const order = getPageOrder(state);

  if (order === 'japanese') {
    return [...pageNumbers].reverse();
  }

  return pageNumbers;
}

function getDisplayPageNumber(
  state: PageOrderState,
  actualPage: number
): number {
  const order = getPageOrder(state);

  if (order === 'japanese') {
    return state.totalPages - actualPage + 1;
  }

  return actualPage;
}

7.5 Extended Keyboard Shortcuts

File: web/src/reader/keyboard-shortcuts.ts

// Extended keyboard shortcuts for all readers
// Procedural implementation (no OOP)

interface KeyboardShortcutHandler {
  onNextPage: () => void;
  onPreviousPage: () => void;
  onNextChapter: () => void;
  onPreviousChapter: () => void;
  onGoToPage: (page: number) => void;
  onToggleBookmark: () => void;
  onZoomIn: () => void;
  onZoomOut: () => void;
  onToggleFullscreen: () => void;
  onClose: () => void;
}

function setupKeyboardShortcuts(
  container: HTMLElement,
  handlers: KeyboardShortcutHandler,
  maxPage: number
): void {
  container.addEventListener('keydown', (e) => {
    if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
      return;
    }

    switch (e.key) {
      case 'ArrowRight':
      case 'PageDown':
      case 'l':
        e.preventDefault();
        handlers.onNextPage();
        break;

      case 'ArrowLeft':
      case 'PageUp':
      case 'h':
        e.preventDefault();
        handlers.onPreviousPage();
        break;

      case 'ArrowUp':
      case 'k':
        e.preventDefault();
        handlers.onPreviousPage();
        break;

      case 'ArrowDown':
      case 'j':
        e.preventDefault();
        handlers.onNextPage();
        break;

      case ' ':
        e.preventDefault();
        handlers.onNextPage();
        break;

      case 'Home':
        e.preventDefault();
        handlers.onGoToPage(1);
        break;

      case 'End':
        e.preventDefault();
        handlers.onGoToPage(maxPage);
        break;

      case 'b':
        if (!e.ctrlKey && !e.metaKey) {
          e.preventDefault();
          handlers.onToggleBookmark();
        }
        break;

      case '+':
      case '=':
        e.preventDefault();
        handlers.onZoomIn();
        break;

      case '-':
      case '_':
        e.preventDefault();
        handlers.onZoomOut();
        break;

      case '0':
        e.preventDefault();
        handlers.onZoomIn();
        handlers.onZoomIn();
        handlers.onZoomIn();
        break;

      case 'f':
        if (!e.ctrlKey && !e.metaKey) {
          e.preventDefault();
          handlers.onToggleFullscreen();
        }
        break;

      case 'Escape':
        e.preventDefault();
        handlers.onClose();
        break;

      default:
        if (e.key >= '1' && e.key <= '9') {
          const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
          e.preventDefault();
          handlers.onGoToPage(targetPage);
        }
    }
  });
}

function showShortcutHelp(): void {
  const help = document.createElement('div');
  help.className = 'keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50';
  help.innerHTML = `
    <div class="bg-gray-800 rounded-lg p-6 max-w-md">
      <h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
      <div class="grid grid-cols-2 gap-4 text-sm">
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">F</kbd> Fullscreen</div>
        <div><kbd class="bg-gray-700 px-2 py-1 rounded">1-9</kbd> Jump to %</div>
      </div>
      <button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
        Close
      </button>
    </div>
  `;
  document.body.appendChild(help);
  help.addEventListener('click', (e) => {
    if (e.target === help) help.remove();
  });
}

7.6 Page Slider/Scrubber

File: web/src/reader/comic/page-scrubber.ts

// Page slider/scrubber for quick navigation
// Procedural implementation (no OOP)

interface PageScrubberState {
  currentPage: number;
  totalPages: number;
  container: HTMLElement;
}

function createPageScrubber(
  container: HTMLElement,
  currentPage: number,
  totalPages: number
): PageScrubberState {
  const state: PageScrubberState = {
    currentPage,
    totalPages,
    container
  };

  renderPageScrubber(state);
  return state;
}

function renderPageScrubber(state: PageScrubberState): void {
  const existing = state.container.querySelector('.page-scrubber');
  existing?.remove();

  const scrubber = document.createElement('div');
  scrubber.className = 'page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40';
  scrubber.innerHTML = `
    <span class="page-label">${state.currentPage}</span>
    <input
      type="range"
      class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
      min="1"
      max="${state.totalPages}"
      value="${state.currentPage}"
    />
    <span class="page-total">${state.totalPages}</span>
  `;

  const slider = scrubber.querySelector('.page-slider') as HTMLInputElement;
  slider.addEventListener('input', (e) => {
    const targetPage = parseInt((e.target as HTMLInputElement).value);
    updatePageScrubber(state, targetPage);
  });

  slider.addEventListener('change', () => {
    const targetPage = parseInt(slider.value);
    navigateToPage(targetPage);
  });

  state.container.appendChild(scrubber);
}

function updatePageScrubber(state: PageScrubberState, currentPage: number): PageScrubberState {
  const newState = { ...state, currentPage };

  const label = state.container.querySelector('.page-label');
  if (label) {
    label.textContent = String(currentPage);
  }

  return newState;
}

function showPageScrubber(state: PageScrubberState): void {
  const scrubber = state.container.querySelector('.page-scrubber');
  scrubber?.classList.remove('hidden');
}

function hidePageScrubber(state: PageScrubberState): void {
  const scrubber = state.container.querySelector('.page-scrubber');
  scrubber?.classList.add('hidden');
}

function navigateToPage(page: number): void {
  window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page } }));
}

7.7 Gesture Controls

File: web/src/reader/gestures.ts

// Touch gesture controls for mobile/tablet
// Procedural implementation (no OOP)

interface GestureHandlers {
  onSwipeLeft: () => void;
  onSwipeRight: () => void;
  onSwipeUp: () => void;
  onSwipeDown: () => void;
  onPinch: (scale: number) => void;
  onTap: () => void;
  onDoubleTap: () => void;
}

interface GestureState {
  touchStartX: number;
  touchStartY: number;
  touchStartTime: number;
  lastTapTime: number;
  initialPinchDistance: number;
  scale: number;
}

function setupGestureControls(
  container: HTMLElement,
  handlers: GestureHandlers
): void {
  let state: GestureState = {
    touchStartX: 0,
    touchStartY: 0,
    touchStartTime: 0,
    lastTapTime: 0,
    initialPinchDistance: 0,
    scale: 1
  };

  container.addEventListener('touchstart', (e) => {
    if (e.touches.length === 1) {
      state.touchStartX = e.touches[0].clientX;
      state.touchStartY = e.touches[0].clientY;
      state.touchStartTime = Date.now();
    } else if (e.touches.length === 2) {
      state.initialPinchDistance = getPinchDistance(e.touches);
    }
  }, { passive: true });

  container.addEventListener('touchend', (e) => {
    const deltaX = e.changedTouches[0].clientX - state.touchStartX;
    const deltaY = e.changedTouches[0].clientY - state.touchStartY;
    const deltaTime = Date.now() - state.touchStartTime;

    if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
      const now = Date.now();
      if (now - state.lastTapTime < 300) {
        handlers.onDoubleTap();
        state.lastTapTime = 0;
      } else {
        state.lastTapTime = now;
        setTimeout(() => {
          if (state.lastTapTime !== 0) {
            handlers.onTap();
          }
        }, 300);
      }
      return;
    }

    const minSwipeDistance = 50;
    const maxSwipeTime = 500;

    if (deltaTime > maxSwipeTime) return;

    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      if (deltaX > minSwipeDistance) {
        handlers.onSwipeRight();
      } else if (deltaX < -minSwipeDistance) {
        handlers.onSwipeLeft();
      }
    } else {
      if (deltaY > minSwipeDistance) {
        handlers.onSwipeDown();
      } else if (deltaY < -minSwipeDistance) {
        handlers.onSwipeUp();
      }
    }
  }, { passive: true });

  container.addEventListener('touchmove', (e) => {
    if (e.touches.length === 2) {
      const currentDistance = getPinchDistance(e.touches);
      if (state.initialPinchDistance > 0) {
        const scale = currentDistance / state.initialPinchDistance;
        state.scale = scale;
        handlers.onPinch(scale);
      }
    }
  }, { passive: true });
}

function getPinchDistance(touches: TouchList): number {
  const dx = touches[0].clientX - touches[1].clientX;
  const dy = touches[0].clientY - touches[1].clientY;
  return Math.sqrt(dx * dx + dy * dy);
}

7.8 Panel Gap Controls

File: web/src/reader/comic/panel-gap.ts

// Adjustable panel gap controls
// Procedural implementation (no OOP)

interface PanelGapState {
  gapSize: number;
  showBorders: boolean;
}

function createPanelGapState(initialGap: number = 4): PanelGapState {
  return {
    gapSize: initialGap,
    showBorders: false
  };
}

function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
  const clampedGap = Math.max(0, Math.min(20, gap));

  document.documentElement.style.setProperty('--panel-gap', `${clampedGap}px`);

  return { ...state, gapSize: clampedGap };
}

function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
  return setPanelGap(state, state.gapSize + amount);
}

function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
  return setPanelGap(state, state.gapSize - amount);
}

function togglePanelBorders(state: PanelGapState): PanelGapState {
  const newShowBorders = !state.showBorders;

  document.documentElement.style.setProperty(
    '--panel-border-width',
    newShowBorders ? '1px' : '0px'
  );

  return { ...state, showBorders: newShowBorders };
}

function renderPanelGapControls(container: HTMLElement, state: PanelGapState): void {
  const existing = container.querySelector('.panel-gap-controls');
  existing?.remove();

  const controls = document.createElement('div');
  controls.className = 'panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40';
  controls.innerHTML = `
    <button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
    <span class="text-center text-sm">${state.gapSize}px</span>
    <button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
    <button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
      ${state.showBorders ? '▦' : '▢'}
    </button>
  `;

  controls.querySelector('.panel-gap-increase')?.addEventListener('click', () => {
    const newState = increasePanelGap(state);
    updatePanelGapUI(controls, newState);
  });

  controls.querySelector('.panel-gap-decrease')?.addEventListener('click', () => {
    const newState = decreasePanelGap(state);
    updatePanelGapUI(controls, newState);
  });

  controls.querySelector('.panel-gap-borders')?.addEventListener('click', () => {
    const newState = togglePanelBorders(state);
    updatePanelGapUI(controls, newState);
  });

  container.appendChild(controls);
}

function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
  const gapLabel = container.querySelector('span');
  if (gapLabel) {
    gapLabel.textContent = `${state.gapSize}px`;
  }

  const bordersBtn = container.querySelector('.panel-gap-borders');
  if (bordersBtn) {
    bordersBtn.textContent = state.showBorders ? '▦' : '▢';
  }
}

const panelGapCSS = `
  :root {
    --panel-gap: 4px;
    --panel-border-width: 0px;
  }

  .panel-zoom-container {
    gap: var(--panel-gap);
  }

  .panel-zoom-container.with-borders {
    background: rgba(255, 255, 255, 0.1);
    padding: var(--panel-gap);
  }

  .panel-borders {
    border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3);
  }
`;

7.9 Background Color Options

File: web/src/reader/comic/background-color.ts

// Background color options for manga/comics
// Procedural implementation (no OOP)

type BackgroundColor = 'black' | 'white' | 'gray' | 'sepia' | 'custom';

interface BackgroundColorState {
  current: BackgroundColor;
  customColor: string;
}

const backgroundColors: Record<BackgroundColor, string> = {
  black: '#000000',
  white: '#ffffff',
  gray: '#333333',
  sepia: '#f4ecd8',
  custom: ''
};

function createBackgroundColorState(
  initial: BackgroundColor = 'black'
): BackgroundColorState {
  return {
    current: initial,
    customColor: '#000000'
  };
}

function setBackgroundColor(
  state: BackgroundColorState,
  color: BackgroundColor,
  customColor?: string
): BackgroundColorState {
  const newState: BackgroundColorState = {
    current: color,
    customColor: customColor || state.customColor
  };

  const bgColor = color === 'custom'
    ? newState.customColor
    : backgroundColors[color];

  document.documentElement.style.setProperty('--reader-bg-color', bgColor);

  const viewer = document.querySelector('.reader-content');
  if (viewer) {
    viewer.style.backgroundColor = bgColor;
  }

  localStorage.setItem('reader-background-color', color);

  return newState;
}

function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState {
  const order: BackgroundColor[] = ['black', 'white', 'gray', 'sepia'];
  const currentIndex = order.indexOf(state.current);
  const nextIndex = (currentIndex + 1) % order.length;

  return setBackgroundColor(state, order[nextIndex]);
}

function renderBackgroundColorPicker(
  container: HTMLElement,
  state: BackgroundColorState
): void {
  const existing = container.querySelector('.background-color-picker');
  existing?.remove();

  const picker = document.createElement('div');
  picker.className = 'background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40';

  const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia'];

  colors.forEach(color => {
    const btn = document.createElement('button');
    btn.className = `w-8 h-8 rounded-full border-2 ${
      state.current === color ? 'border-blue-500' : 'border-transparent'
    }`;
    btn.style.backgroundColor = backgroundColors[color];
    btn.title = color.charAt(0).toUpperCase() + color.slice(1);
    btn.addEventListener('click', () => {
      const newState = setBackgroundColor(state, color);
      updateBackgroundColorUI(picker, newState);
    });
    picker.appendChild(btn);
  });

  container.appendChild(picker);
}

function updateBackgroundColorUI(container: HTMLElement, state: BackgroundColorState): void {
  const buttons = container.querySelectorAll('button');
  const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia'];

  buttons.forEach((btn, index) => {
    btn.classList.toggle('border-blue-500', colors[index] === state.current);
  });
}

7.10 Chapter Markers

File: web/src/reader/comic/chapter-markers.ts

// Chapter markers for manga/comics
// Visual indicators for chapter boundaries
// Procedural implementation (no OOP)

interface ChapterInfo {
  chapterNumber: number;
  pageStart: number;
  pageEnd: number;
  title?: string;
}

interface ChapterMarkerState {
  chapters: ChapterInfo[];
  currentChapter: number;
  showMarkers: boolean;
}

function createChapterMarkerState(
  chapters: ChapterInfo[],
  currentPage: number
): ChapterMarkerState {
  const currentChapter = chapters.find(
    c => currentPage >= c.pageStart && currentPage <= c.pageEnd
  )?.chapterNumber || 1;

  return {
    chapters,
    currentChapter,
    showMarkers: true
  };
}

function renderChapterMarkers(
  container: HTMLElement,
  state: ChapterMarkerState
): void {
  if (!state.showMarkers) return;

  const markersContainer = document.createElement('div');
  markersContainer.className = 'chapter-markers absolute left-0 right-0 pointer-events-none z-10';

  state.chapters.forEach(chapter => {
    const marker = document.createElement('div');
    marker.className = 'chapter-marker flex items-center gap-2 text-sm text-gray-400';

    const isCurrentChapter = chapter.chapterNumber === state.currentChapter;

    marker.style.position = 'absolute';
    marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`;
    marker.style.left = '10px';

    marker.innerHTML = `
      <span class="chapter-number ${isCurrentChapter ? 'text-blue-400 font-bold' : ''}">
        ${chapter.title || `Chapter ${chapter.chapterNumber}`}
      </span>
      <span class="page-number text-xs">p.${chapter.pageStart}</span>
      ${isCurrentChapter ? '<span class="current-indicator">←</span>' : ''}
    `;

    markersContainer.appendChild(marker);
  });

  const existing = container.querySelector('.chapter-markers');
  existing?.remove();
  container.appendChild(markersContainer);
}

function updateCurrentChapter(
  state: ChapterMarkerState,
  currentPage: number
): ChapterMarkerState {
  const currentChapter = state.chapters.find(
    c => currentPage >= c.pageStart && currentPage <= c.pageEnd
  )?.chapterNumber || state.currentChapter;

  if (currentChapter !== state.currentChapter) {
    const newState = { ...state, currentChapter };

    const markers = document.querySelector('.chapter-markers');
    if (markers) {
      renderChapterMarkers(markers.parentElement!, newState);
    }

    return newState;
  }

  return state;
}

function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
  const newState = { ...state, showMarkers: !state.showMarkers };

  const markers = document.querySelector('.chapter-markers');
  if (markers) {
    markers.classList.toggle('hidden', !newState.showMarkers);
  }

  return newState;
}

function scrollToChapter(
  state: ChapterMarkerState,
  chapterNumber: number
): void {
  const chapter = state.chapters.find(c => c.chapterNumber === chapterNumber);
  if (chapter) {
    window.dispatchEvent(new CustomEvent('navigate-to-page', {
      detail: { page: chapter.pageStart }
    }));
  }
}

const chapterMarkerCSS = `
  .chapter-marker {
    padding: 4px 8px;
    margin-left: -18px;
    opacity: 0.7;
    transition: opacity 0.2s;
  }

  .chapter-marker:hover {
    opacity: 1;
  }

  .chapter-marker .current-indicator {
    color: #3b82f6;
    animation: pulse 2s infinite;
  }

  @keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
  }

  .chapter-marker-line {
    position: absolute;
    left: 0;
    right: 0;
    height: 1px;
    background: linear-gradient(to right, rgba(255,255,255,0.1), transparent);
  }
`;

8. Lazy Loading & Caching

8.1 Page Cache (5-Page Ahead)

File: web/src/reader/comic/page-cache.ts

// 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)

// 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)

{
  "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)

// 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:

{
  "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

// 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

// 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)

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:

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:

// 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 media_bookmarks table with chapter_number and cfi_position fields)
  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