Files
bookhoard/READER_IMPLEMENTATION_PLAN.md
T
john-okeefe c1bc9dceb5 Add complete manga reader implementation section to plan
Added Section 8: Manga Reader Implementation with 5 subsections:

8.1 RTL Navigator (Right-to-Left Reading)
- Reverse page turn direction for traditional manga
- Proper key bindings for RTL navigation
- Progress tracking adapted for RTL

8.2 Vertical Scroll Mode (Webtoon Style)
- Infinite vertical scroll for webtoons/manhwa
- Lazy loading with threshold-based prefetching
- Scroll position to page number mapping
- Memory-efficient blob URL cleanup

8.3 Reading Direction Detection
- Auto-detection from metadata (manga_type, reading_direction)
- Filename-based heuristics (manga, manhwa, webtoon)
- User preference support with fallback
- Integration with database schema

8.4 Manga Settings Integration
- Reading direction preference (auto/ltr/rtl/vertical)
- Vertical scroll speed control
- RTL page transition effects
- Settings persistence via API

8.5 Manga Page Cache (shared with comics)
- 5-page ahead prefetching
- Memory management with cleanup
- Shared caching strategy for comics and manga

This fills the gap where manga was architecturally planned but had no
implementation details. Database schema, types, and UI already supported
manga - now the implementation is documented.

Plan now has complete coverage for all 4 media types: ebook, comic, manga, pdf
2026-04-03 23:38:46 -04:00

327 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, navigator, copy support
  • Modular dockable panels: TOC, Settings, Navigator, Bookmarks - each independent, dockable to either side, window-shade 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> { ... }

Phase 0: Prerequisites & Setup ⚠️ MUST COMPLETE FIRST

IMPORTANT: Do not start implementation until these prerequisites are complete. Failure to complete these steps will result in compilation errors and missing functionality.

0.1 Database Schema Setup

Step 1: The database tables are already defined in Section 2.1 of this document. Copy the SQL from Section 2.1 (lines 563-641) and add it to:

File: database/schema/schema.sql (modify existing)

Add the SQL at the end of the file, before the index section (around line 1105).

Step 2: Also add the ALTER TABLE statement from Section 2.2 to add chapter_metadata column to media_items table.

Step 3: After adding the tables, regenerate database queries:

cd /home/nymusicman/Code/bookhoard/database
sqlc generate

Step 4: Update your local database:

Option 1: Recreate database (LOSES ALL DATA - Recommended for development):

podman compose down -v  # Delete all volumes
podman compose up -d    # Start with fresh schema

Option 2: Manual SQL migration (preserves data):

podman exec bookhoard_db psql -U postgres -d bookhoard
# Then paste the SQL from Section 2.1 and 2.2

0.2 Database Queries Setup

File: internal/database/queries/queries.sql (modify existing)

Add these queries to support the reader functionality:

-- name: GetPanelData :one
SELECT * FROM panel_data
WHERE media_item_id = $1 AND page_number = $2;

-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)
ON CONFLICT (media_item_id, page_number)
DO UPDATE SET
    detection_method = EXCLUDED.detection_method,
    panels = EXCLUDED.panels,
    updated_at = NOW()
RETURNING *;

-- name: GetReadingSpeed :one
SELECT * FROM reading_speed
WHERE user_id = $1 AND media_item_id = $2;

-- name: CreateReadingSpeed :one
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;

-- name: UpdateReadingSpeed :one
UPDATE reading_speed
SET
    pages_per_minute = $3,
    pages_read = pages_read + $4,
    total_reading_minutes = total_reading_minutes + $5,
    last_read_at = $6,
    updated_at = NOW()
WHERE user_id = $1 AND media_item_id = $2
RETURNING *;

-- name: GetDictionaryEntry :one
SELECT * FROM dictionary_cache
WHERE word = $1;

-- name: CreateDictionaryEntry :one
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING *;

-- name: UpdateDictionaryAccessed :one
UPDATE dictionary_cache
SET accessed_at = NOW()
WHERE word = $1
RETURNING *;

-- name: GetReaderSettings :one
SELECT setting_value FROM reader_settings
WHERE user_id = $1 AND setting_key = 'reader_settings';

-- name: UpsertReaderSettings :one
INSERT INTO reader_settings (user_id, setting_key, setting_value)
VALUES ($1, 'reader_settings', $2)
ON CONFLICT (user_id, setting_key)
DO UPDATE SET
    setting_value = EXCLUDED.setting_value,
    updated_at = NOW()
RETURNING *;

-- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2
ORDER BY created_at DESC;

-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;

-- name: DeleteMediaBookmark :exec
DELETE FROM media_bookmarks
WHERE id = $1;

-- name: UpdateMediaBookmark :one
UPDATE media_bookmarks
SET
    title = $2,
    notes = $3,
    position = $4,
    updated_at = NOW()
WHERE id = $1 AND user_id = $5
RETURNING *;

After adding the queries, regenerate:

cd /home/nymusicman/Code/bookhoard/database
sqlc generate

0.3 Frontend Dependencies

File: package.json (modify existing)

Add these dependencies to the dependencies section:

{
  "dependencies": {
    "jszip": "^3.10.1",
    "pdfjs-dist": "^3.11.174"
  }
}

Then install:

npm install

0.4 Directory Structure

Create the required directory structure:

# Frontend reader directories
mkdir -p web/src/reader/ebook
mkdir -p web/src/reader/comic
mkdir -p web/src/reader/manga
mkdir -p web/src/reader/pdf

# Fonts directory
mkdir -p web/static/fonts

# Bruno API test directory
mkdir -p bruno/reader

0.5 Pre-Implementation Checklist

Before starting Phase 1, verify all items are complete:

  • Database: Added SQL from Section 2.1 to database/schema/schema.sql
  • Database: Added ALTER TABLE from Section 2.2 to database/schema/schema.sql
  • Database: Added queries to internal/database/queries/queries.sql (from this section)
  • Database: Ran sqlc generate to regenerate Go code
  • Database: Updated local database (recreated or migrated)
  • Frontend: Added jszip and pdfjs-dist to package.json
  • Frontend: Ran npm install to install dependencies
  • Structure: Created all required directories (mkdir commands above)
  • Verification: Can run go build ./... without errors
  • Verification: Database tables exist (check with \dt in psql)

0.6 Verification Commands

Verify database tables exist:

podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks"

Expected output should show 5 tables.

Verify queries were generated:

grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed\|GetDictionaryEntry\|GetReaderSettings\|GetMediaBookmarks" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go
# Should return count > 0 (at least 10-12 matches)

Verify frontend dependencies:

npm list jszip pdfjs-dist
# Should show versions, not "(empty)"

Expected output:

bookhoard@1.0.0 /home/nymusicman/Code/bookhoard
├── jszip@3.10.1
└── pdfjs-dist@3.11.174

Verify Go compilation:

go build ./...
# Should complete without errors

Verify directories exist:

ls -la web/src/reader/ | grep -E "ebook|comic|manga|pdf"
# Should show 4 directories

0.7 Troubleshooting

Problem: sqlc generate fails with "undefined type" Solution: Make sure you copied the SQL from Section 2.1 EXACTLY as written

Problem: Database tables don't show up after recreation Solution: Check that SQL was added BEFORE the index section in schema.sql

Problem: npm install fails Solution: Try npm cache clean --force then npm install again

Problem: go build fails with "undefined: GetPanelData" Solution: Make sure you ran sqlc generate after adding queries

Problem: psql shows "column does not exist" for chapter_metadata Solution: Make sure you ran the ALTER TABLE command from Section 2.2


File: internal/database/queries/queries.sql (modify existing)

Add these queries to the queries.sql file:

-- name: GetPanelData :one
SELECT * FROM panel_data
WHERE media_item_id = $1 AND page_number = $2;

-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)
ON CONFLICT (media_item_id, page_number)
DO UPDATE SET
    detection_method = EXCLUDED.detection_method,
    panels = EXCLUDED.panels,
    updated_at = NOW()
RETURNING *;

-- name: GetReadingSpeed :one
SELECT * FROM reading_speed
WHERE user_id = $1 AND media_item_id = $2;

-- name: CreateReadingSpeed :one
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;

-- name: UpdateReadingSpeed :one
UPDATE reading_speed
SET
    pages_per_minute = $3,
    pages_read = pages_read + $4,
    total_reading_minutes = total_reading_minutes + $5,
    last_read_at = $6,
    updated_at = NOW()
WHERE user_id = $1 AND media_item_id = $2
RETURNING *;

-- name: GetDictionaryEntry :one
SELECT * FROM dictionary_cache
WHERE word = $1;

-- name: CreateDictionaryEntry :one
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING *;

-- name: UpdateDictionaryAccessed :one
UPDATE dictionary_cache
SET accessed_at = NOW()
WHERE word = $1
RETURNING *;

-- name: GetReaderSettings :one
SELECT setting_value FROM reader_settings
WHERE user_id = $1 AND setting_key = 'reader_settings';

-- name: UpsertReaderSettings :one
INSERT INTO reader_settings (user_id, setting_key, setting_value)
VALUES ($1, 'reader_settings', $2)
ON CONFLICT (user_id, setting_key)
DO UPDATE SET
    setting_value = EXCLUDED.setting_value,
    updated_at = NOW()
RETURNING *;

-- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2
ORDER BY created_at DESC;

-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;

-- name: DeleteMediaBookmark :exec
DELETE FROM media_bookmarks
WHERE id = $1;

Regenerate queries after adding:

cd /home/nymusicman/Code/bookhoard/database
sqlc generate

0.3 Frontend Dependencies

File: package.json (modify existing)

Add these dependencies to the dependencies section:

{
  "dependencies": {
    "jszip": "^3.10.1",
    "pdfjs-dist": "^3.11.174"
  }
}

Then install:

npm install

0.4 Directory Structure

Create the required directory structure:

# Frontend reader directories
mkdir -p web/src/reader/ebook
mkdir -p web/src/reader/comic
mkdir -p web/src/reader/manga
mkdir -p web/src/reader/pdf

# Fonts directory
mkdir -p web/static/fonts

# Bruno API test directory
mkdir -p bruno/reader

0.5 Pre-Implementation Checklist

Before starting Phase 1, verify all items are complete:

  • Database: Added 5 new tables to database/schema/schema.sql
  • Database: Added SQL queries to internal/database/queries/queries.sql
  • Database: Ran sqlc generate to regenerate Go code
  • Database: Updated local database (recreated or migrated)
  • Frontend: Added jszip and pdfjs-dist to package.json
  • Frontend: Ran npm install to install dependencies
  • Structure: Created all required directories
  • Verification: Can run go build ./... without errors
  • Verification: Database tables exist (check with \dt in psql)

0.6 Verification Commands

Verify database tables exist:

podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks"

Verify queries were generated:

grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go
# Should return count > 0

Verify frontend dependencies:

npm list jszip pdfjs-dist
# Should show versions, not "(empty)"

Verify Go compilation:

go build ./...
# Should complete without errors

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
├── panel-dock-system.ts     - Modular dockable panel system
│   ├── panel-container.ts  - Base panel container with dock logic
│   │   - Lock toggle: prevents accidental drag/move
│   │   - Snap-back: returns to last valid position if dropped in invalid area
│   │   - Dock zones: left edge, right edge, valid drop targets
│   │   - Drag handlers: mouse/touch drag to reposition
│   │   - Drop zone detection: validates drop position
│   │   - Persist state: saves to settings on move/lock/collapse
│   ├── window-shade.ts     - Vertical collapse to title bar
│   │   - Animated collapse/expand
│   │   - State persistence
│   └── panel-state.ts      - Per-user layout persistence (includes lock state)
├── 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

Dockable Panel Components (Reusable)
├── toc-panel.ts            - Table of Contents (dockable left/right, window-shade)
├── settings-panel.ts      - Reader settings (dockable left/right, window-shade)
├── navigator-panel.ts      - Page navigator with viewport box (dockable left/right, window-shade)
└── bookmarks-panel.ts     - User bookmarks (dockable left/right, window-shade, default bottom-right)

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-rotation.ts          - Rotated page support
└── pdf-page-sizes.ts        - Variable page size handling

Comic Reader (Image Archives) - Uses shared navigator-panel.ts for page navigation
├── 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 (when detection ON)
└── 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 /readers/:mediaItemId (SSR skeleton page)
    ↓
Verify access, fetch initial metadata, progress, bookmarks
    ↓
SSR render: templates/reader.templ (skeleton shell with data attributes)
    - Passes: metadata, progress, bookmarks for initial display
    - Renders: chrome UI, panel containers (empty), data attributes
    - Does NOT render: actual content pages (loaded by JS)
    ↓
Frontend TypeScript: reader-shell.ts initializes
    - Reads: data-media-item-id, data-media-type, data-total-pages
    - Detects: media type (ebook/pdf/comic/manga)
    - Loads: appropriate reader (EbookReader/PDFReader/ComicReader/MangaReader)
    ↓
Reader fetches content dynamically:
    - Ebooks: /readers/:id/pages/:n (HTML chapters)
    - Comics: /readers/:id/pages/:n (images)
    - PDFs: /readers/:id/pages/:n (text layer + images)
    ↓
User interacts (turn page, highlight, bookmark)
    - All UI logic handled by TypeScript
    - Progress updates sent via WebSocket
    ↓
Real-time sync via WebSocket (reuse existing system)

1.5 SSR vs Client-Side Responsibilities

IMPORTANT: The reader uses a hybrid approach - SSR provides the shell, TypeScript handles all interactivity.

SSR (Go template) responsibilities:

  • Render HTML skeleton/structure
  • Embed initial metadata (title, author, cover)
  • Embed initial progress (current page, percentage)
  • Embed initial bookmarks list
  • Render chrome UI (top/bottom bars with basic info)
  • Render panel containers (empty, populated by JS)
  • Set data attributes for Alpine.js initialization
  • Does NOT render actual content pages
  • Does NOT handle reader interactions

Client-Side (TypeScript) responsibilities:

  • Read data attributes from DOM
  • Detect media type and initialize appropriate reader
  • Fetch and render content pages dynamically
  • Handle all user interactions (page turns, zoom, pan)
  • Manage panel state (show/hide, dock, collapse)
  • Sync progress via WebSocket
  • Handle highlights, annotations, bookmarks CRUD
  • Implement reading features (dictionary, search, TOC navigation)

Example: What SSR provides:

<body data-media-item-id="123" data-media-type="ebook" data-total-pages="300">
  <div id="reader-chrome">
    <h1>Book Title</h1>  <!-- From metadata -->
    <span>Page 45/300</span>  <!-- From progress -->
  </div>
  <main id="reader-content">
    <!-- Empty - JavaScript will load pages here -->
  </main>
  <div id="toc-panel" class="panel-container">
    <!-- Empty shell - JavaScript populates TOC -->
  </div>
</body>

Example: What TypeScript does:

// reader-shell.ts
function initReader() {
  const mediaItemId = document.body.dataset.mediaItemId;
  const mediaType = document.body.dataset.mediaType;
  
  // Fetch and render first page
  loadPage(mediaItemId, 1, mediaType);
  
  // Initialize panel interactions
  initPanelSystem();
  
  // Setup WebSocket sync
  initWebSocketSync(mediaItemId);
}

2. Database Schema Changes

2.1 New Tables

-- Panel detection data
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id);

-- Reading speed tracking
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);

-- Dictionary cache (for offline use)
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);

-- Reader settings (per-user preferences)
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);

-- PDF bookmarks (custom user bookmarks)
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
CREATE INDEX IF NOT EXISTS 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

2.4 Required Code Implementation (Database & Go Backend)

Database Tables (Already Exist)

The following tables already exist in database/schema/schema.sql:

  • panel_data - Panel detection for comics/manga
  • reader_settings - Stores panel layout configuration (including dock positions, lock state, collapsed state, width)
  • media_bookmarks - User bookmarks
  • reading_speed - Reading statistics
  • dictionary_cache - Offline dictionary

Panel layout storage: All panel dock system state (side, visible, collapsed, width_px, order, locked, last_valid_side) is stored as JSON in reader_settings.setting_value under the panel_layout key.

Database Schema Issues to Fix

The following database schema issues need to be resolved before implementation:

  1. media_items.chapter_metadata: Add to schema as JSONB column (see Section 2.2)

  2. reader_settings table query: The generated query UpsertReaderSettings expects:

    • Parameters: UserID (UUID), SettingValue ([]byte)
    • The query uses setting_key = 'reader_settings' hardcoded
    • Code using SettingKey field will fail - remove that field from params
  3. reading_speed table fields: The generated model uses different types:

    • pages_per_minute is pgtype.Float8, not pgtype.Numeric
    • pages_read is pgtype.Int4, not plain int32
    • Fix type conversions in service code
  4. panel_data table: The generated model database.PanelData is a struct, not a type with Valid/Bytes fields

    • Code using cached.Valid and cached.Bytes will fail
    • Need to query directly and handle JSON unmarshaling differently

Database Queries to Add

Add these queries to internal/database/queries/queries.sql:

-- name: UpdateMediaItemChapterMetadata :one
UPDATE media_items
SET chapter_metadata = $2, updated_at = NOW()
WHERE id = $1
RETURNING *;

-- name: GetReadingSpeedByUser :one
SELECT * FROM reading_speed
WHERE user_id = $1 AND media_item_id = $2;

-- name: UpsertReadingSpeed :one
INSERT INTO reading_speed (user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET
    words_per_minute = COALESCE($3, reading_speed.words_per_minute),
    pages_per_minute = COALESCE($4, reading_speed.pages_per_minute),
    pages_read = reading_speed.pages_read + COALESCE($5, 0),
    total_reading_minutes = reading_speed.total_reading_minutes + COALESCE($6, 0),
    last_read_at = $7,
    updated_at = NOW()
RETURNING *;

Go Service Implementation Issues

The following issues in internal/services/reader.go need fixing:

  1. Line 71-76: Chapter metadata is []byte, not pgtype.JSONB

    // Change from:
    if item.ChapterMetadata.Valid { ... item.ChapterMetadata.Bytes ... }
    // To:
    if len(item.ChapterMetadata) > 0 { ... item.ChapterMetadata ... }
    
  2. Line 126: PageCount is pgtype.Int4, use .Int32 not .Int64

    // Change from:
    pageCount := int(item.PageCount.Int64)
    // To:
    pageCount := int(item.PageCount.Int32)
    
  3. Lines 172-174: PanelData query returns database.PanelData struct, handle differently

  4. Lines 283-285: Fix pgtype conversions for reading_speed

    // Use proper pgtype types:
    PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true},
    PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true},
    TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true},
    
  5. Lines 353, 386-387: Fix reader settings access

    // SettingValue is []byte, use directly
    if len(settings.SettingValue) > 0 { ... }
    // UpsertReaderSettingsParams doesn't have SettingKey
    // Use: UserID, SettingValue only
    

Router Implementation

The router at internal/router/reader.go has a placeholder SSR handler that needs:

  1. Fetch metadata/progress/bookmarks (inline or via handler)
  2. Import "bookhoard/templates"
  3. Call templates.Reader() with proper data

Note: This creates import cycle if handler imports templates - follow frontend.go pattern where router handles template rendering directly.


3. API Endpoints

3.1 Reader Routes

IMPORTANT: Reader routes are split into two locations following Bookhoard's architecture:

  • SSR page (HTML): Registered in internal/router/frontend.go
  • API endpoints (JSON): Registered in internal/router/reader.go

File: internal/router/reader.go (new file)

package router

import (
    "bookhoard/internal/handlers"
    "bookhoard/internal/services"
)

func registerReaderRoutes(cfg *Config) {
    e := cfg.Echo

    // Create reader service and handler
    readerService := services.NewReaderService(cfg.Queries, cfg.Worker)
    cfg.ReaderHandler = handlers.NewReaderHandler(
        cfg.Queries,
        services.NewLibraryService(cfg.Queries),  // Create inline, not from cfg
        readerService,
        cfg.Worker,
    )

    jwtMiddleware := createJWTMiddleware(cfg)

    // API routes only (JSON responses)
    // Note: SSR reader page is registered in frontend.go under /readers/:mediaItemId
    reader := e.Group("/readers", jwtMiddleware)

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

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

    // **PHASE 1 NOTE:** Do NOT register parse route in Phase 1
    // **PHASE 2.5 TASK:** Uncomment below when implementing server-side parsers
    // // Parse endpoint for server-side formats (MOBI, AZW3, DOCX, RTF)
    // reader.POST("/:mediaItemId/parse", cfg.ReaderHandler.ParseEbook)
}

File: internal/router/frontend.go (modify existing)

Add the SSR reader page route to the frontendProtected group:

// In registerFrontendRoutes function, after other frontendProtected routes:

// Reader page (SSR)
frontendProtected.GET("/readers/:mediaItemId", func(c *echo.Context) error {
    // Get user
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return renderErrorPage(c, "Error loading user", "user_load_error")
    }

    // Parse mediaItemID
    mediaItemID := c.Param("mediaItemId")
    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return renderErrorPage(c, "Invalid media item ID", "invalid_id")
    }

    // Fetch media item
    mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
    if err != nil {
        if err == pgx.ErrNoRows {
            return renderErrorPage(c, "Media item not found", "not_found")
        }
        return renderErrorPage(c, "Failed to fetch media item", "db_error")
    }

    // Check library access (using GetUserVisibleLibraries pattern)
    userUUID, _ := uuid.Parse(user.ID)
    libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
    if err != nil {
        return renderErrorPage(c, "Failed to check access", "access_error")
    }

    visible := false
    for _, lib := range libraries {
        if lib.ID == mediaItem.LibraryID {
            visible = true
            break
        }
    }
    if !visible {
        return renderErrorPage(c, "Access denied to this library", "access_denied")
    }

    // Get reading progress
    var progress database.ReadingProgress
    progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
        MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
        UserID:      uuidToPGType(userUUID),
    })
    if err != nil && err != pgx.ErrNoRows {
        progress = database.ReadingProgress{}
    }

    // Get bookmarks
    bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
        MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
        UserID:      uuidToPGType(userUUID),
    })

    // Convert to template types (inline, following frontend.go pattern)
    mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
    metadata := templates.ReaderMetadata{
        MediaItemID:    mediaUUID.String(),
        Title:          mediaItem.Title,
        Author:         textToString(mediaItem.Author),
        CoverImagePath: textToString(mediaItem.CoverImagePath),
        LibraryType:    mediaItem.FormatGroup,
        MimeType:       textToString(mediaItem.MimeType),
        FilePath:       mediaItem.FilePath,
        TotalPages:     int(mediaItem.PageCount.Int32),
        ChapterCount:   int(mediaItem.ChapterCount.Int32),
    }

    // Progress conversion (inline)
    progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
    progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
    progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
    templateProgress := templates.ReadingProgress{
        ID:          progressUUID.String(),
        MediaItemID: progressMediaUUID.String(),
        UserID:      progressUserUUID.String(),
        CurrentPage: int(progress.CurrentPage.Int32),
        TotalPages:  int(progress.TotalPages.Int32),
        Percentage:  progress.Percentage.Float64,
        EpubCfi:     textToString(progress.Epubcfi),
        LastReadAt:  progress.LastReadAt.Time,
    }

    // Bookmarks conversion (inline, with loop)
    templateBookmarks := make([]templates.Bookmark, len(bookmarks))
    for i, b := range bookmarks {
        bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
        bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
        bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])

        var pageNumber *int
        if b.PageNumber.Valid {
            pn := int(b.PageNumber.Int32)
            pageNumber = &pn
        }

        var chapterNumber *int
        if b.ChapterNumber.Valid {
            cn := int(b.ChapterNumber.Int32)
            chapterNumber = &cn
        }

        templateBookmarks[i] = templates.Bookmark{
            ID:            bookmarkUUID.String(),
            MediaItemID:   bookmarkMediaUUID.String(),
            UserID:        bookmarkUserUUID.String(),
            PageNumber:    pageNumber,
            ChapterNumber: chapterNumber,
            CfiPosition:   textToString(b.CfiPosition),
            Title:         b.Title,
            Position:      textToString(b.Position),
            Notes:         textToString(b.Notes),
            CreatedAt:     b.CreatedAt.Time,
        }
    }

    // Render template
    var buf bytes.Buffer
    err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
    if err != nil {
        return renderErrorPage(c, "Error rendering reader", "render_error")
    }

    return c.HTML(http.StatusOK, buf.String())
})

File: internal/router/router.go (modify existing)

Add ReaderHandler to Config struct (around line 38-67):

type Config struct {
    Echo                  *echo.Echo
    Queries               *database.Queries
    Cfg                   *config.Config
    DBPool                interface{}
    AuthHandler           *handlers.AuthHandler
    LibraryHandler        *handlers.LibraryHandler
    DeviceHandler         *handlers.DeviceHandler
    MediaHandler          *handlers.MediaHandler
    MatchingHandler       *handlers.MatchingHandler
    KOReaderHandler       *handlers.KOReaderHandler
    WSHandler             *handlers.WSHandler
    ConflictHandler       *handlers.ConflictHandler
    AnalyticsHandler      *handlers.AnalyticsHandler
    QueueHandler          *handlers.QueueHandler
    CollectionHandler     *handlers.CollectionHandler
    Worker                *services.Worker
    FiltersHandler        *handlers.FiltersHandler
    DashboardHandler      *handlers.DashboardHandler
    DashboardService      *services.DashboardService
    OPDSHandler           *handlers.OPDSHandler
    SystemSettingsHandler *handlers.SystemSettingsHandler
    ConnManager           *sync.ConnectionManager
    QueueProcessor        *sync.SyncQueueProcessor
    DeviceAuthMiddleware  *middleware.DeviceAuthMiddleware
    LoginTracker          *ratelimit.LoginAttemptTracker
    ScannerHandler        *handlers.Handler
    JobsHandler           *handlers.JobsHandler
    SidecarHandler        *handlers.SidecarHandler
    ReaderHandler         *handlers.ReaderHandler  // ADD THIS LINE
}

Add registerReaderRoutes call in RegisterRoutes function (after line 220):

func RegisterRoutes(cfg *Config) *handlers.Handler {
    // ... existing route registrations ...
    
    registerFiltersRoutes(cfg)
    registerOPDSRoutes(cfg)
    registerReaderRoutes(cfg)  // ADD THIS LINE - reader routes must be registered before frontend routes
    registerWebSocketRoutes(cfg)
    registerFrontendRoutes(cfg)
    registerDocumentationRoutes(cfg)
    
    // ... rest of function ...
}

3.2 Handler Implementation

IMPORTANT: Handlers return JSON only (API endpoints). SSR rendering is handled in router/frontend.go to avoid circular dependencies between handlers and templates.

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)
  • DO NOT import templates package (causes circular dependency)

ShowReader method is removed - SSR is handled by router/frontend.go, not handlers

API Handler Structure:

package handlers

import (
    "bookhoard/internal/database"
    "bookhoard/internal/services"
    "context"
    "fmt"
    "net/http"
    "os"
    "strconv"

    "github.com/google/uuid"
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgtype"
    "github.com/labstack/echo/v5"
)

type ReaderHandler struct {
    db             *database.Queries
    libraryService *services.LibraryService
    readerService  *services.ReaderService
    worker         *services.Worker
}

func NewReaderHandler(
    db *database.Queries,
    libraryService *services.LibraryService,
    readerService *services.ReaderService,
    worker ...*services.Worker,
) *ReaderHandler {
    rh := &ReaderHandler{
        db:             db,
        libraryService: libraryService,
        readerService:  readerService,
    }
    if len(worker) > 0 && worker[0] != nil {
        rh.worker = worker[0]
    }
    return rh
}

// NOTE: No ShowReader method here - SSR is handled by router/frontend.go
// All methods below return JSON for API endpoints only

// GetPage returns a specific page for lazy loading
func (h *ReaderHandler) GetPage(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    pageNumber := c.Param("pageNumber")

    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    page, err := strconv.Atoi(pageNumber)
    if err != nil || page < 1 {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
    }

    // Get media item
    mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
    }

    // Check user access
    user := c.Get("user").(database.Users)
    hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID)
    if !hasAccess {
        return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"})
    }

    // Resolve full file path
    fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
    }

    // Get requested format
    format := c.QueryParam("format")
    if format == "" {
        format = "html"
    }

    // Extract page content based on format
    content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "content":      content,
        "page_number":  page,
        "total_pages":  mediaItem.PageCount,
        "media_item_id": mediaItemID,
    })
}

// GetChapters returns chapter metadata
func (h *ReaderHandler) GetChapters(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    // Use reader service to detect chapters
    chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"})
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "chapters": chapters,
    })
}

// GetPanels returns panel detection data for comics
func (h *ReaderHandler) GetPanels(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    pageNumber := c.Param("pageNumber")

    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    page, err := strconv.Atoi(pageNumber)
    if err != nil || page < 1 {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
    }

    // Get detection method from query
    method := c.QueryParam("method")
    if method == "" {
        method = "grid"
    }

    // Use reader service to detect panels
    panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"})
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "page_number":       page,
        "detection_method":  method,
        "panels":            panels,
    })
}

// UpdatePanels allows manual panel override
func (h *ReaderHandler) UpdatePanels(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    pageNumber := c.Param("pageNumber")

    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    page, err := strconv.Atoi(pageNumber)
    if err != nil || page < 1 {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
    }

    // Parse request body
    var req struct {
        Panels           []services.Panel `json:"panels"`
        DetectionMethod  string           `json:"detection_method"`
    }

    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
    }

    // Store manual panel override in database
    // This would require implementing UpsertPanelData in database/queries.sql
    _ = parsedUUID
    _ = page
    _ = req

    return c.JSON(http.StatusOK, map[string]interface{}{
        "success": true,
        "message": "Panels updated successfully",
    })
}

// GetReadingSpeed retrieves reading speed statistics
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    user := c.Get("user").(database.Users)

    // Get reading speed from database
    speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{
        UserID:      user.ID,
        MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
    })

    if err != nil {
        if err == pgx.ErrNoRows {
            // Return zero values if no reading has occurred
            return c.JSON(http.StatusOK, map[string]interface{}{
                "words_per_minute":      0,
                "pages_per_minute":      0,
                "pages_read":           0,
                "total_reading_minutes": 0,
                "last_read_at":         nil,
            })
        }
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"})
    }

    return c.JSON(http.StatusOK, map[string]interface{}{
        "words_per_minute":      speed.PagesPerMinute.Float64 * 250, // Estimate WPM
        "pages_per_minute":      speed.PagesPerMinute.Float64,
        "pages_read":           speed.PagesRead,
        "total_reading_minutes": speed.TotalReadingMinutes.Float64,
        "last_read_at":         speed.LastReadAt.Time,
    })
}

// UpdateReadingSpeed updates reading speed statistics
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    user := c.Get("user").(database.Users)

    // Parse request body
    var req struct {
        PagesRead          int     `json:"pages_read"`
        TimeSpentMinutes   float64 `json:"time_spent_minutes"`
    }

    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
    }

    // Update reading speed using service
    err = h.readerService.CalculateReadingSpeed(
        c.Request().Context(),
        user.ID,
        parsedUUID,
        req.PagesRead,
        req.TimeSpentMinutes,
    )

    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"})
    }

    // Calculate and return updated statistics
    pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes
    wordsPerMinute := pagesPerMinute * 250 // Estimate

    return c.JSON(http.StatusOK, map[string]interface{}{
        "success":           true,
        "words_per_minute":  wordsPerMinute,
        "pages_per_minute":  pagesPerMinute,
    })
}

// GetPDFOutline returns PDF outline/TOC
func (h *ReaderHandler) GetPDFOutline(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    // Get media item
    mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
    }

    // Only PDFs have outlines
    if mediaItem.FormatGroup != "pdf" {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"})
    }

    // Extract PDF outline using pdfcpu
    fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
    }

    // Use pdfcpu to extract outline
    outline := h.extractPDFOutline(fullPath)

    return c.JSON(http.StatusOK, map[string]interface{}{
        "outline": outline,
    })
}

// GetPDFThumbnail returns a thumbnail for PDF mini-map
func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error {
    mediaItemID := c.Param("mediaItemId")
    pageNumber := c.Param("pageNumber")

    parsedUUID, err := uuid.Parse(mediaItemID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
    }

    page, err := strconv.Atoi(pageNumber)
    if err != nil || page < 1 {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
    }

    // Get media item
    mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
    }

    // Get thumbnail size from query
    width := c.QueryParam("width")
    height := c.QueryParam("height")

    // Generate thumbnail using pdfcpu
    fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
    }

    thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"})
    }

    c.Response().Header().Set("Content-Type", "image/png")
    return c.Blob(http.StatusOK, "image/png", thumbnail)
}

// LookupWord performs dictionary lookup
func (h *ReaderHandler) LookupWord(c echo.Context) error {
    word := c.Param("word")
    if word == "" {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"})
    }

    // Use reader service for dictionary lookup
    entry, err := h.readerService.LookupWord(c.Request().Context(), word)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"})
    }

    return c.JSON(http.StatusOK, entry)
}

// GetSettings retrieves user's reader settings
func (h *ReaderHandler) GetSettings(c echo.Context) error {
    user := c.Get("user").(database.Users)

    // Use reader service to get settings
    settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
    }

    return c.JSON(http.StatusOK, settings)
}

// UpdateSettings updates user's reader settings
func (h *ReaderHandler) UpdateSettings(c echo.Context) error {
    user := c.Get("user").(database.Users)

    // Parse request body (partial update supported)
    var settings map[string]interface{}
    if err := c.Bind(&settings); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
    }

    // Validate settings
    if readingTheme, ok := settings["reading_theme"].(string); ok {
        validThemes := map[string]bool{
            "light": true, "sepia": true, "dark": true, "night": true, "high-contrast": true,
        }
        if !validThemes[readingTheme] {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"})
        }
    }

    // Use reader service to update settings
    err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, settings)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
    }

    // Return updated settings
    updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID)
    return c.JSON(http.StatusOK, updatedSettings)
}

// Helper functions

func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
    // Get library folders
    folders, err := h.db.GetLibraryFolders(ctx, libraryID)
    if err != nil {
        return "", err
    }

    // Try each folder until we find the file
    for _, folder := range folders {
        fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath
        if _, err := os.Stat(fullPath); err == nil {
            return fullPath, nil
        }
    }

    return "", fmt.Errorf("file not found in any library folder")
}

func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) {
    // Extract content based on format
    // This is a simplified implementation
    // In production, would use format-specific parsers

    switch item.FormatGroup {
    case "reflowable":
        // For EPUB and other ebooks, extract the requested page/chapter
        return h.extractEbookPage(fullPath, page, format)
    case "fixed-layout":
        // For comics, return image data URL or path
        return h.extractComicPage(fullPath, page, format)
    case "pdf":
        // For PDFs, extract text or image
        return h.extractPDFPage(fullPath, page, format)
    default:
        return "", fmt.Errorf("unsupported format: %s", item.FormatGroup)
    }
}

func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) {
    // Simplified EPUB extraction
    // In production, would use epub-parser.ts logic
    return fmt.Sprintf("<div class='ebook-page'><p>Page %d content</p></div>", page), nil
}

func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) {
    // For comics, return image path or data URL
    return fmt.Sprintf("/readers/comic-image?page=%d", page), nil
}

func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) {
    // For PDFs, extract text content or image
    return fmt.Sprintf("<div class='pdf-page'><p>Page %d content</p></div>", page), nil
}

func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} {
    // Extract PDF outline using pdfcpu
    // This is a placeholder
    return []map[string]interface{}{}
}

func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) {
    // Generate thumbnail using pdfcpu
    // This is a placeholder
    return []byte{}, nil
}

// Helper function to convert pgtype.Text to string
func textToString(t pgtype.Text) string {
    if !t.Valid {
        return ""
    }
    return t.String
}

3.3 Service Layer

File: internal/services/reader_service.go (new file)

All business logic goes here, not in handlers:

package services

import (
    "bookhoard/internal/database"
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/google/uuid"
    "github.com/jackc/pgx/v5/pgtype"
)

type ReaderService struct {
    db     *database.Queries
    worker *Worker
}

func NewReaderService(db *database.Queries, worker *Worker) *ReaderService {
    return &ReaderService{
        db:     db,
        worker: worker,
    }
}

// Chapter represents a detected chapter
type Chapter struct {
    ID         string  `json:"id"`
    Title      string  `json:"title"`
    StartPage  int     `json:"start_page"`
    PageCount  int     `json:"page_count"`
    Level      int     `json:"level"`
    ParentID   *string `json:"parent_id,omitempty"`
}

// Panel represents a detected comic panel
type Panel struct {
    ID           string `json:"id"`
    X            int    `json:"x"`
    Y            int    `json:"y"`
    Width        int    `json:"width"`
    Height       int    `json:"height"`
    ReadingOrder int    `json:"reading_order"`
}

// DictionaryEntry represents a word definition
type DictionaryEntry struct {
    Word         string `json:"word"`
    Definition   string `json:"definition"`
    PartOfSpeech string `json:"part_of_speech,omitempty"`
    Example      string `json:"example,omitempty"`
    Etymology    string `json:"etymology,omitempty"`
}

// ChapterDetectionResult contains chapter metadata
type ChapterDetectionResult struct {
    Chapters   []Chapter            `json:"chapters"`
    Metadata   json.RawMessage      `json:"metadata,omitempty"`
    DetectedAt time.Time            `json:"detected_at"`
}

// DetectChapters analyzes a media item to detect chapter structure
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) {
    // Get media item to determine type
    item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
    if err != nil {
        return nil, fmt.Errorf("failed to get media item: %w", err)
    }

    // Check if chapter metadata already exists
    if item.ChapterMetadata.Valid {
        var existing ChapterDetectionResult
        if err := json.Unmarshal(item.ChapterMetadata.Bytes, &existing); err == nil {
            return existing.Chapters, nil
        }
    }

    // Detect chapters based on format
    var chapters []Chapter

    switch item.FormatGroup {
    case "reflowable":
        // For ebooks, parse from TOC if available
        chapters, err = s.detectEbookChapters(ctx, &item)
    case "fixed-layout":
        // For comics/manga, detect page breaks as chapters
        chapters, err = s.detectComicChapters(ctx, &item)
    case "pdf":
        // For PDFs, use PDF outline
        chapters, err = s.detectPDFChapters(ctx, &item)
    default:
        chapters = []Chapter{}
    }

    if err != nil {
        return nil, fmt.Errorf("chapter detection failed: %w", err)
    }

    // Cache the results
    result := ChapterDetectionResult{
        Chapters:   chapters,
        Metadata:   nil,
        DetectedAt: time.Now(),
    }

    metadataBytes, err := json.Marshal(result)
    if err == nil {
        // Update media item with chapter metadata
        // This would require a new query in database/queries.sql
        _ = metadataBytes
    }

    return chapters, nil
}

func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
    // For EPUB files, parse the TOC from the OPF file
    // This requires EPUB parsing (see epub-parser.ts)
    // For now, return empty structure
    return []Chapter{}, nil
}

func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
    // For comics, treat each page as a potential chapter
    // or group pages by story arcs if metadata exists
    pageCount := int(item.PageCount.Int64)

    if pageCount <= 0 {
        return []Chapter{}, nil
    }

    chapters := make([]Chapter, 0)
    chapterSize := 20 // Group pages into chapters of 20 pages each

    for i := 0; i < pageCount; i += chapterSize {
        endPage := i + chapterSize
        if endPage > pageCount {
            endPage = pageCount
        }

        chapters = append(chapters, Chapter{
            ID:        fmt.Sprintf("chapter-%d", len(chapters)+1),
            Title:     fmt.Sprintf("Page %d-%d", i+1, endPage),
            StartPage: i + 1,
            PageCount: endPage - i,
            Level:     1,
        })
    }

    return chapters, nil
}

func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
    // For PDFs, use pdfcpu to extract outline/bookmarks
    // This requires PDF parsing library
    return []Chapter{}, nil
}

// DetectPanels analyzes a comic page to detect panel boundaries
func (s *ReaderService) DetectPanels(
    ctx context.Context,
    mediaItemID uuid.UUID,
    pageNumber int,
    method string,
) ([]Panel, error) {
    // Check if panels already exist in cache
    cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{
        MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
        PageNumber:  int32(pageNumber),
    })

    if err == nil && cached.Valid {
        var panels []Panel
        if err := json.Unmarshal(cached.Bytes, &panels); err == nil {
            return panels, nil
        }
    }

    // Detect panels using specified method
    var panels []Panel

    switch method {
    case "grid":
        panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
    case "ml":
        panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber)
    case "manual":
        panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber)
    default:
        // Default to grid detection
        panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
    }

    if err != nil {
        return nil, fmt.Errorf("panel detection failed: %w", err)
    }

    // Cache the results
    panelsJSON, _ := json.Marshal(panels)
    // Insert into panel_data table
    _ = panelsJSON

    return panels, nil
}

func (s *ReaderService) detectPanelsGrid(
    ctx context.Context,
    mediaItemID uuid.UUID,
    pageNumber int,
) ([]Panel, error) {
    // Simple grid-based panel detection
    // Divide page into 2x2 or 3x3 grid
    // This is a simplified implementation

    panels := []Panel{
        {
            ID:           "panel-1",
            X:            0,
            Y:            0,
            Width:        50,
            Height:       100,
            ReadingOrder: 1,
        },
        {
            ID:           "panel-2",
            X:            50,
            Y:            0,
            Width:        50,
            Height:       100,
            ReadingOrder: 2,
        },
    }

    return panels, nil
}

func (s *ReaderService) detectPanelsML(
    ctx context.Context,
    mediaItemID uuid.UUID,
    pageNumber int,
) ([]Panel, error) {
    // ML-based panel detection
    // This would require a trained model
    // For now, fall back to grid detection
    return s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
}

func (s *ReaderService) detectPanelsManual(
    ctx context.Context,
    mediaItemID uuid.UUID,
    pageNumber int,
) ([]Panel, error) {
    // Manual panel detection returns existing manually-set panels
    // These would be stored in the panel_data table
    return []Panel{}, nil
}

// CalculateReadingSpeed updates reading speed statistics
func (s *ReaderService) CalculateReadingSpeed(
    ctx context.Context,
    userID uuid.UUID,
    mediaItemID uuid.UUID,
    pagesRead int,
    minutes float64,
) error {
    if minutes <= 0 {
        return fmt.Errorf("invalid time: must be positive")
    }

    pagesPerMinute := float64(pagesRead) / minutes

    // Get or create reading speed record
    _, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{
        UserID:      pgtype.UUID{Bytes: userID, Valid: true},
        MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
    })

    if err != nil {
        // Create new record
        _, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{
            UserID:            pgtype.UUID{Bytes: userID, Valid: true},
            MediaItemID:       pgtype.UUID{Bytes: mediaItemID, Valid: true},
            PagesPerMinute:    pgtype.Float8{Float64: pagesPerMinute, Valid: true},
            PagesRead:         int32(pagesRead),
            TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true},
            LastReadAt:        pgtype.Timestamptz{Time: time.Now(), Valid: true},
        })
    } else {
        // Update existing record with moving average
        // This would require an UpdateReadingSpeed query
        _ = pagesPerMinute
    }

    return err
}

// LookupWord retrieves dictionary entry for a word
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) {
    // Check cache first
    cached, err := s.db.GetDictionaryEntry(ctx, word)

    if err == nil {
        return &DictionaryEntry{
            Word:         cached.Word,
            Definition:   cached.Definition,
            PartOfSpeech: cached.PartOfSpeech.String,
            Example:      cached.Example.String,
            Etymology:    cached.Etymology.String,
        }, nil
    }

    // Not in cache, fetch from dictionary API
    entry, err := s.fetchDictionaryEntry(ctx, word)
    if err != nil {
        return nil, err
    }

    // Cache the entry
    _, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{
        Word:         entry.Word,
        Definition:   entry.Definition,
        PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""},
        Example:      pgtype.Text{String: entry.Example, Valid: entry.Example != ""},
        Etymology:    pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""},
    })

    return entry, nil
}

func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) {
    // Fetch from external dictionary API
    // For now, return a placeholder
    return &DictionaryEntry{
        Word:       word,
        Definition: fmt.Sprintf("Definition for %s", word),
    }, nil
}

// GetSettings retrieves reader settings for a user
func (s *ReaderService) GetSettings(
    ctx context.Context,
    userID uuid.UUID,
) (map[string]interface{}, error) {
    // Get settings from database
    settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true})

    if err != nil {
        // Return default settings
        return s.getDefaultSettings(), nil
    }

    var result map[string]interface{}
    if err := json.Unmarshal(settings.Bytes, &result); err != nil {
        return s.getDefaultSettings(), nil
    }

    return result, nil
}

// UpdateSettings updates reader settings for a user
func (s *ReaderService) UpdateSettings(
    ctx context.Context,
    userID uuid.UUID,
    settings map[string]interface{},
) error {
    // Merge with existing settings
    existing, err := s.GetSettings(ctx, userID)
    if err != nil {
        existing = s.getDefaultSettings()
    }

    // Merge settings (partial update)
    for key, value := range settings {
        existing[key] = value
    }

    // Serialize and save
    settingsJSON, err := json.Marshal(existing)
    if err != nil {
        return fmt.Errorf("failed to serialize settings: %w", err)
    }

    // Update in database
    _, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{
        UserID:       pgtype.UUID{Bytes: userID, Valid: true},
        SettingKey:   "reader_settings",
        SettingValue: string(settingsJSON),
    })

    return err
}

func (s *ReaderService) getDefaultSettings() map[string]interface{} {
    return map[string]interface{}{
        "chrome_behavior":    "auto-hide",
        "progress_mode":      "pages",
        "chrome_theme":       "tokyo-night",
        "reading_theme":      "dark",
        "reading_font":       "literata",
        "font_size":          16,
        "line_height":        1.6,
        "margin_width":       20,
        "tap_zone_size":      30,
        "auto_scroll":        false,
        "panel_zoom_enabled": true,
    }
}

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;

  // Dockable panel layout configuration (per-user, media-type-aware)
  panel_layout: PanelLayoutSettings;
}

interface PanelLayoutSettings {
  // Per panel state: which side, visible, collapsed (window-shade), width
  toc: PanelState;
  settings: PanelState;
  navigator: PanelState;
  bookmarks: PanelState;
  
  // Mobile override
  mobile_nav_visible: boolean;
}

interface PanelState {
  side: 'left' | 'right' | 'hidden';
  visible: boolean;
  collapsed: boolean;  // Window-shade: true = collapsed to title bar
  width_px: number;    // Panel width in pixels
  order: number;       // Order within the side
  locked: boolean;      // Lock toggle: prevents accidental drag/move
  last_valid_side: 'left' | 'right' | 'hidden';  // Snap-back target if dropped in invalid area
}

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

PHASE 1 SCOPE: Stub only - returns "not implemented" for complex formats

File: internal/handlers/reader.go (add ParseEbook method)

package handlers

import (
	"bookhoard/internal/database"
	"bookhoard/internal/services"
	"fmt"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/labstack/echo/v5"
	"github.com/google/uuid"
)

// ParseEbook handles server-side parsing for complex ebook formats
// **PHASE 1:** Returns 501 "not implemented" for MOBI/AZW3/DOCX/RTF
// **PHASE 2.5:** Implement actual parsing logic
//
// **NOTE:** This method is NOT registered as a route in Phase 1
// Route registration happens in Phase 2.5
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(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
	if err != nil {
		if err == pgx.ErrNoRows {
			return c.JSON(404, map[string]string{"error": "Media item not found"})
		}
		return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
	}

	// Check if format requires server-side parsing
	requiresServerParsing := false
	formatName := ""
	switch mediaItem.MimeType.String {
	case "application/x-mobipocket-ebook":
		requiresServerParsing = true
		formatName = "MOBI"
	case "application/vnd.amazon.mobi8-ebook":
		requiresServerParsing = true
		formatName = "AZW3/KF8"
	case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
		requiresServerParsing = true
		formatName = "DOCX"
	case "application/rtf":
		requiresServerParsing = true
		formatName = "RTF"
	}

	if requiresServerParsing {
		// Phase 1: Return 501 for server-side formats
		return c.JSON(501, map[string]interface{}{
			"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
			"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
			"suggestion": "Please convert your ebook to EPUB format for now",
			"format": formatName,
			"phase": "2.5",
		})
	}

	// Client-side format - should not call this endpoint
	return c.JSON(400, map[string]string{
		"error": "This format should be parsed client-side, not on the server",
	})
}

PHASE 1: That's it - just the stub above. No parser implementations needed.

PHASE 2.5: See Section 14.5 for complete parser implementations (parseMOBI, parseAZW3, parseDOCX, parseRTF)


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,

    // Dockable panel defaults by media type
    panel_layout: {
      toc: { side: 'left', visible: true, collapsed: false, width_px: 320, order: 1, locked: false, last_valid_side: 'left' },
      settings: { side: 'left', visible: false, collapsed: true, width_px: 380, order: 2, locked: false, last_valid_side: 'left' },
      navigator: { side: 'right', visible: true, collapsed: false, width_px: 200, order: 1, locked: false, last_valid_side: 'right' },
      bookmarks: { side: 'right', visible: false, collapsed: true, width_px: 280, order: 2, locked: false, last_valid_side: 'right' },
      mobile_nav_visible: false
    }
  };
}

4.7 Panel Dock System (Modular Dockable Panels)

File: web/src/reader/panel-dock-system.ts

// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Procedural style: Functions, not classes

import { saveSettings, loadSettings, getDefaultSettings } from "./settings-manager";

interface PanelDockState {
  panels: Map<string, PanelState>;
  dragState: DragState | null;
  dockZones: DockZone[];
}

interface DragState {
  panelId: string;
  startX: number;
  startY: number;
  currentX: number;
  currentY: number;
  isLocked: boolean;
}

interface DockZone {
  side: 'left' | 'right';
  x: number;
  width: number;
  height: number;
}

const state: PanelDockState = {
  panels: new Map(),
  dragState: null,
  dockZones: [
    { side: 'left', x: 0, width: 400, height: window.innerHeight },
    { side: 'right', x: window.innerWidth - 400, width: 400, height: window.innerHeight }
  ]
};

// Initialize all panels from settings
function initializePanelDockSystem(): void {
  const settings = loadSettings();
  
  for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
    registerPanel(panelId, panelState);
  }
  
  setupDragHandlers();
  setupWindowShadeHandlers();
  setupLockHandlers();
}

// Register a panel with the dock system
function registerPanel(panelId: string, panelState: PanelState): void {
  state.panels.set(panelId, panelState);
  applyPanelState(panelId, panelState);
}

// Apply panel state to DOM
function applyPanelState(panelId: string, panelState: PanelState): void {
  const panel = document.querySelector(`[data-panel="${panelId}"]`);
  if (!panel) return;
  
  const container = panel.parentElement;
  if (!container) return;
  
  // Apply side positioning
  if (panelState.side === 'left') {
    container.style.left = '0';
    container.style.right = 'auto';
  } else if (panelState.side === 'right') {
    container.style.right = '0';
    container.style.left = 'auto';
  } else {
    container.style.left = '-9999px';
  }
  
  // Apply width
  panel.style.width = `${panelState.width_px}px`;
  
  // Apply collapsed (window-shade) state
  if (panelState.collapsed) {
    panel.classList.add('panel-collapsed');
    panel.querySelector('.panel-content')?.classList.add('hidden');
  } else {
    panel.classList.remove('panel-collapsed');
    panel.querySelector('.panel-content')?.classList.remove('hidden');
  }
  
  // Apply lock state
  const lockBtn = panel.querySelector('.panel-lock');
  if (lockBtn) {
    lockBtn.textContent = panelState.locked ? '🔒' : '🔓';
  }
}

// Setup mouse/touch drag handlers
function setupDragHandlers(): void {
  document.querySelectorAll('.dockable-panel .panel-header').forEach(header => {
    header.addEventListener('mousedown', handleDragStart);
    header.addEventListener('touchstart', handleDragStart, { passive: false });
  });
  
  document.addEventListener('mousemove', handleDragMove);
  document.addEventListener('touchmove', handleDragMove, { passive: false });
  document.addEventListener('mouseup', handleDragEnd);
  document.addEventListener('touchend', handleDragEnd);
}

function handleDragStart(e: MouseEvent | TouchEvent): void {
  const header = e.target.closest('.panel-header') as HTMLElement;
  const panel = header?.closest('.dockable-panel') as HTMLElement;
  if (!panel) return;
  
  const panelId = panel.dataset.panel;
  const panelState = state.panels.get(panelId);
  
  // Check if locked
  if (panelState?.locked) return;
  
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
  
  state.dragState = {
    panelId: panelId!,
    startX: clientX,
    startY: clientY,
    currentX: clientX,
    currentY: clientY,
    isLocked: panelState?.locked || false
  };
  
  panel.classList.add('dragging');
}

function handleDragMove(e: MouseEvent | TouchEvent): void {
  if (!state.dragState) return;
  
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
  
  state.dragState.currentX = clientX;
  state.dragState.currentY = clientY;
  
  const panel = document.querySelector(`[data-panel="${state.dragState.panelId}"]`);
  const container = panel?.parentElement;
  if (container) {
    container.style.transform = `translateX(${clientX - state.dragState.startX}px)`;
  }
}

function handleDragEnd(e: MouseEvent | TouchEvent): void {
  if (!state.dragState) return;
  
  const { panelId, currentX } = state.dragState;
  const panel = document.querySelector(`[data-panel="${panelId}"]`);
  const container = panel?.parentElement;
  
  // Reset transform
  container.style.transform = '';
  panel?.classList.remove('dragging');
  
  // Determine drop zone
  const newSide = currentX < window.innerWidth / 2 ? 'left' : 'right';
  const panelState = state.panels.get(panelId);
  
  if (panelState) {
    // Check if dropped in valid zone
    const isValidDrop = newSide === 'left' || newSide === 'right';
    
    if (isValidDrop) {
      panelState.last_valid_side = panelState.side; // Save previous valid position
      panelState.side = newSide;
    } else {
      // Snap back to last valid position
      panelState.side = panelState.last_valid_side;
    }
    
    applyPanelState(panelId, panelState);
    savePanelState(panelId, panelState);
  }
  
  state.dragState = null;
}

// Setup window-shade (collapse/expand) handlers
function setupWindowShadeHandlers(): void {
  document.querySelectorAll('.window-shade-toggle').forEach(btn => {
    btn.addEventListener('click', (e) => {
      const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement;
      const panelId = panel.dataset.panel;
      const panelState = state.panels.get(panelId);
      
      if (panelState) {
        panelState.collapsed = !panelState.collapsed;
        applyPanelState(panelId, panelState);
        savePanelState(panelId, panelState);
      }
    });
  });
}

// Setup lock toggle handlers
function setupLockHandlers(): void {
  document.querySelectorAll('.panel-lock').forEach(btn => {
    btn.addEventListener('click', (e) => {
      const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement;
      const panelId = panel.dataset.panel;
      const panelState = state.panels.get(panelId);
      
      if (panelState) {
        panelState.locked = !panelState.locked;
        applyPanelState(panelId, panelState);
        savePanelState(panelId, panelState);
      }
    });
  });
}

// Persist panel state to settings
async function savePanelState(panelId: string, panelState: PanelState): Promise<void> {
  const settings = loadSettings();
  settings.panel_layout[panelId as keyof typeof settings.panel_layout] = panelState;
  await saveSettings(settings);
}

export { initializePanelDockSystem, registerPanel, applyPanelState };

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

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"

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.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 Navigator Panel (Affinity Image Editor Style)

File: web/src/reader/navigator-panel.ts

// Navigator panel - shows full page with draggable viewport box
// Affinity/Photoshop-style mini-map for page navigation
// Procedural implementation (no OOP)

import { loadSettings } from "./settings-manager";

interface NavigatorState {
  panelId: string;
  container: HTMLElement;
  viewport: HTMLElement;
  currentPage: number;
  totalPages: number;
  scale: number;
  contentImage: HTMLImageElement | null;
  isDragging: boolean;
}

function initializeNavigator(containerSelector: string): NavigatorState {
  const container = document.querySelector(containerSelector) as HTMLElement;
  if (!container) throw new Error("Navigator container not found");
  
  const viewport = document.createElement('div');
  viewport.className = 'navigator-viewport-box';
  viewport.style.cssText = `
    position: absolute;
    border: 2px solid var(--accent-color, #3b82f6);
    background: rgba(59, 130, 246, 0.1);
    cursor: move;
    z-index: 10;
  `;
  
  container.appendChild(viewport);
  
  const state: NavigatorState = {
    panelId: 'navigator',
    container,
    viewport,
    currentPage: 1,
    totalPages: 1,
    scale: 0.1,
    contentImage: null,
    isDragging: false
  };
  
  setupNavigatorDragHandler(state);
  return state;
}

// Setup draggable viewport box within navigator
function setupNavigatorDragHandler(state: NavigatorState): void {
  state.viewport.addEventListener('mousedown', (e) => {
    e.preventDefault();
    state.isDragging = true;
    state.viewport.style.cursor = 'grabbing';
  });
  
  document.addEventListener('mousemove', (e) => {
    if (!state.isDragging || !state.contentImage) return;
    
    const containerRect = state.container.getBoundingClientRect();
    const imgRect = state.contentImage.getBoundingClientRect();
    
    // Calculate position relative to scaled image
    const relX = (e.clientX - imgRect.left) / imgRect.width;
    const relY = (e.clientY - imgRect.top) / imgRect.height;
    
    // Update main viewer's position (call external handler)
    const mainViewer = document.getElementById('reader-content');
    if (mainViewer) {
      mainViewer.dataset.panX = relX.toString();
      mainViewer.dataset.panY = relY.toString();
      // Dispatch event for main viewer to handle
      mainViewer.dispatchEvent(new CustomEvent('navigator-pan', { 
        detail: { x: relX, y: relY } 
      }));
    }
  });
  
  document.addEventListener('mouseup', () => {
    state.isDragging = false;
    state.viewport.style.cursor = 'move';
  });
}

// Update navigator with current page image
async function updateNavigatorContent(state: NavigatorState, pageNumber: number): Promise<void> {
  state.currentPage = pageNumber;
  
  // Get current page image (from PDF viewer, comic reader, or manga reader)
  const contentArea = document.getElementById('reader-content');
  const img = contentArea?.querySelector('img, canvas') as HTMLImageElement | HTMLCanvasElement | null;
  
  if (!img) return;
  
  // Create thumbnail version for navigator
  const thumb = document.createElement('img');
  thumb.src = img.src || (img as HTMLCanvasElement).toDataURL();
  thumb.style.cssText = `
    width: 100%;
    height: auto;
    display: block;
    pointer-events: none;
  `;
  
  // Clear and populate container
  state.container.innerHTML = '';
  state.container.appendChild(thumb);
  state.contentImage = thumb;
  
  // Recreate viewport box
  const viewport = document.createElement('div');
  viewport.className = 'navigator-viewport-box';
  viewport.style.cssText = `
    position: absolute;
    border: 2px solid var(--accent-color, #3b82f6);
    background: rgba(59, 130, 246, 0.1);
    cursor: move;
    z-index: 10;
    width: ${100 / state.scale}%;
    height: ${100 / state.scale}%;
  `;
  state.container.appendChild(viewport);
  state.viewport = viewport;
  
  // Re-attach drag handler
  setupNavigatorDragHandler(state);
  
  // Calculate viewport size relative to container
  const containerRect = state.container.getBoundingClientRect();
  const viewportWidth = (containerRect.width / img.width) * 100;
  const viewportHeight = (containerRect.height / img.height) * 100;
  
  viewport.style.width = `${viewportWidth}%`;
  viewport.style.height = `${viewportHeight}%`;
}

// Handle window resize
function handleNavigatorResize(state: NavigatorState): void {
  if (state.contentImage) {
    updateNavigatorContent(state, state.currentPage);
  }
}

export { initializeNavigator, updateNavigatorContent, handleNavigatorResize };

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. Manga Reader Implementation

Manga extends the comic reader with specialized reading modes:

  • RTL (Right-to-Left) - Traditional Japanese manga reading
  • Vertical Scroll - Webtoon/manhwa style (infinite vertical scroll)

8.1 RTL Navigator (Right-to-Left Reading)

File: web/src/reader/manga/rtl-navigator.ts

// Right-to-left navigation for manga
// Reverses page turn direction and key bindings

interface RTLNavigatorState {
  currentPage: number;
  totalPages: number;
  readingDirection: 'rtl' | 'ltr';
}

function createRTLNavigator(totalPages: number): RTLNavigatorState {
  return {
    currentPage: 1,
    totalPages,
    readingDirection: 'rtl'
  };
}

function getNextPage(state: RTLNavigatorState): number {
  // In RTL, "next" page means moving left (decreasing page number)
  if (state.readingDirection === 'rtl') {
    return Math.max(1, state.currentPage - 1);
  }
  return Math.min(state.totalPages, state.currentPage + 1);
}

function getPreviousPage(state: RTLNavigatorState): number {
  // In RTL, "previous" page means moving right (increasing page number)
  if (state.readingDirection === 'rtl') {
    return Math.min(state.totalPages, state.currentPage + 1);
  }
  return Math.max(1, state.currentPage - 1);
}

function navigateToPage(state: RTLNavigatorState, pageNumber: number): RTLNavigatorState {
  return {
    ...state,
    currentPage: Math.max(1, Math.min(state.totalPages, pageNumber))
  };
}

function getProgress(state: RTLNavigatorState): { current: number; total: number } {
  return {
    current: state.currentPage,
    total: state.totalPages
  };
}

function getReadingProgressPercentage(state: RTLNavigatorState): number {
  return (state.currentPage / state.totalPages) * 100;
}

8.2 Vertical Scroll Mode (Webtoon Style)

File: web/src/reader/manga/vertical-scroll-mode.ts

// Vertical scroll mode for webtoons/manhwa
// Infinite scroll with image loading and lazy rendering

interface VerticalScrollState {
  container: HTMLElement;
  loadedPages: Set<number>;
  loadingPages: Set<number>;
  currentPage: number;
  totalPages: number;
  threshold: number;  // Distance from bottom to trigger next page load
  mediaItemId: string;
}

function createVerticalScroll(
  container: HTMLElement,
  mediaItemId: string,
  totalPages: number
): VerticalScrollState {
  const state: VerticalScrollState = {
    container,
    loadedPages: new Set(),
    loadingPages: new Set(),
    currentPage: 1,
    totalPages,
    threshold: 500,  // Load next page when 500px from bottom
    mediaItemId
  };

  // Initial page load
  loadPage(state, 1);
  
  // Setup scroll listener
  setupScrollListener(state);

  return state;
}

async function loadPage(
  state: VerticalScrollState,
  pageNumber: number
): Promise<void> {
  if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
    return;
  }

  state.loadingPages.add(pageNumber);

  try {
    const token = localStorage.getItem('token');
    const response = await fetch(
      `/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 imgUrl = URL.createObjectURL(blob);

    const pageContainer = document.createElement('div');
    pageContainer.className = 'vertical-page';
    pageContainer.dataset.pageNumber = pageNumber.toString();

    const img = document.createElement('img');
    img.src = imgUrl;
    img.alt = `Page ${pageNumber}`;
    img.loading = 'lazy';

    pageContainer.appendChild(img);
    state.container.appendChild(pageContainer);

    state.loadedPages.add(pageNumber);
    state.loadingPages.delete(pageNumber);

    // Load next pages proactively
    if (pageNumber < state.totalPages) {
      loadPage(state, pageNumber + 1);
      if (pageNumber + 1 < state.totalPages) {
        loadPage(state, pageNumber + 2);
      }
    }
  } catch (error) {
    console.error(`Failed to load page ${pageNumber}:`, error);
    state.loadingPages.delete(pageNumber);
  }
}

function setupScrollListener(state: VerticalScrollState): void {
  let scrollTimeout: number | undefined;

  state.container.addEventListener('scroll', () => {
    clearTimeout(scrollTimeout);
    scrollTimeout = window.setTimeout(() => {
      checkScrollPosition(state);
    }, 100);
  });
}

function checkScrollPosition(state: VerticalScrollState): void {
  const scrollBottom = state.container.scrollHeight - 
                       state.container.scrollTop - 
                       state.container.clientHeight;

  if (scrollBottom < state.threshold) {
    const lastPage = Math.max(...state.loadedPages);
    if (lastPage < state.totalPages) {
      loadPage(state, lastPage + 1);
    }
  }

  // Update current page based on scroll position
  const currentPage = getCurrentPageFromScroll(state);
  if (currentPage !== state.currentPage) {
    state.currentPage = currentPage;
    // Dispatch event for progress tracking
    window.dispatchEvent(new CustomEvent('page-change', {
      detail: { page: currentPage }
    }));
  }
}

function getCurrentPageFromScroll(state: VerticalScrollState): number {
  const pages = state.container.querySelectorAll('.vertical-page');
  
  for (const page of pages) {
    const rect = page.getBoundingClientRect();
    const containerRect = state.container.getBoundingClientRect();
    
    // Page is considered "current" if it's in the middle 50% of viewport
    const pageMiddle = rect.top + rect.height / 2;
    const viewportMiddle = containerRect.top + containerRect.height / 2;
    
    if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
      return parseInt(page.dataset.pageNumber || '1');
    }
  }

  return state.currentPage;
}

function destroyVerticalScroll(state: VerticalScrollState): void {
  // Clean up blob URLs
  const images = state.container.querySelectorAll('img');
  images.forEach(img => {
    const url = img.src;
    if (url.startsWith('blob:')) {
      URL.revokeObjectURL(url);
    }
  });
  
  state.container.innerHTML = '';
  state.loadedPages.clear();
  state.loadingPages.clear();
}

8.3 Reading Direction Detection

File: web/src/reader/manga/reading-direction.ts

// Detect reading direction from metadata or user preference

type ReadingDirection = 'auto' | 'ltr' | 'rtl' | 'vertical';

interface ReadingDirectionState {
  direction: ReadingDirection;
  detectedDirection: 'ltr' | 'rtl' | 'vertical';
  userPreference: ReadingDirection | null;
}

async function detectReadingDirection(
  metadata: MediaItemMetadata
): Promise<ReadingDirectionState> {
  // Check user preference first
  const userPreference = await getUserReadingDirectionPreference();
  if (userPreference && userPreference !== 'auto') {
    return {
      direction: userPreference,
      detectedDirection: 'ltr', // Default fallback
      userPreference
    };
  }

  // Detect from metadata
  const detectedDirection = detectFromMetadata(metadata);

  return {
    direction: 'auto',
    detectedDirection,
    userPreference: null
  };
}

function detectFromMetadata(metadata: MediaItemMetadata): 'ltr' | 'rtl' | 'vertical' {
  // Check manga_type field from database
  const mangaType = (metadata as any).manga_type;
  if (mangaType === 'yes_and_right_to_left' || mangaType === 'yes') {
    return 'rtl';
  }

  // Check reading_direction field
  const readingDirection = (metadata as any).reading_direction;
  if (readingDirection === 'rtl' || readingDirection === 'vertical') {
    return readingDirection;
  }

  // Detect from filename
  const filename = metadata.filePath.toLowerCase();
  if (filename.includes('manga') || filename.includes('manhwa') || filename.includes('webtoon')) {
    return 'vertical';
  }

  // Default to LTR
  return 'ltr';
}

async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
  const userId = localStorage.getItem('userId');
  if (!userId) return null;

  try {
    const response = await fetch(`/api/users/${userId}/settings`);
    if (!response.ok) return null;

    const settings = await response.json();
    return settings.reading_direction || null;
  } catch {
    return null;
  }
}

function getEffectiveDirection(state: ReadingDirectionState): 'ltr' | 'rtl' | 'vertical' {
  if (state.direction !== 'auto') {
    return state.direction as 'ltr' | 'rtl' | 'vertical';
  }
  return state.detectedDirection;
}

function shouldUseRTL(state: ReadingDirectionState): boolean {
  return getEffectiveDirection(state) === 'rtl';
}

function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
  return getEffectiveDirection(state) === 'vertical';
}

8.4 Manga Settings Integration

File: web/src/reader/manga/settings.ts

// Manga-specific settings integration
// Extends the common reader settings manager

interface MangaSettings {
  readingDirection: 'auto' | 'ltr' | 'rtl' | 'vertical';
  verticalScrollSpeed: 'slow' | 'normal' | 'fast';
  rtlPageTransition: 'slide' | 'fade' | 'none';
  webtoonMode: boolean;
}

async function getMangaSettings(): Promise<MangaSettings> {
  const defaultSettings: MangaSettings = {
    readingDirection: 'auto',
    verticalScrollSpeed: 'normal',
    rtlPageTransition: 'slide',
    webtoonMode: false
  };

  try {
    const userId = localStorage.getItem('userId');
    const response = await fetch(`/api/users/${userId}/settings`);
    
    if (response.ok) {
      const settings = await response.json();
      return { ...defaultSettings, ...settings };
    }
  } catch (error) {
    console.error('Failed to load manga settings:', error);
  }

  return defaultSettings;
}

async function updateMangaSettings(
  settings: Partial<MangaSettings>
): Promise<void> {
  const userId = localStorage.getItem('userId');
  
  try {
    const response = await fetch(`/api/users/${userId}/settings`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${localStorage.getItem('token')}`
      },
      body: JSON.stringify(settings)
    });

    if (!response.ok) {
      throw new Error('Failed to update manga settings');
    }
  } catch (error) {
    console.error('Failed to save manga settings:', error);
  }
}

function applyMangaSettings(settings: MangaSettings): void {
  // Apply reading direction
  document.documentElement.dataset.readingDirection = settings.readingDirection;
  
  // Apply vertical scroll speed
  if (settings.verticalScrollSpeed === 'slow') {
    document.documentElement.style.scrollBehavior = 'smooth';
  } else if (settings.verticalScrollSpeed === 'fast') {
    document.documentElement.style.scrollBehavior = 'auto';
  }
  
  // Apply RTL page transition
  if (settings.rtlPageTransition !== 'none') {
    document.documentElement.dataset.pageTransition = settings.rtlPageTransition;
  }
}

8.5 Manga Page Cache (Shared with Comics)

File: web/src/reader/comic/page-cache.ts (shared for both comics and manga)

// Lazy-loading page cache with 5-page ahead prefetch
// Shared by both comic and manga readers

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 { ...state, page: img };
}

async function loadComicPage(
  state: PageCacheState,
  pageNumber: number
): Promise<HTMLImageElement> {
  const token = localStorage.getItem('token');
  const response = await fetch(
    `/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('/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('/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)

            <!-- Dockable Panels Container -->
            <div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
                <!-- Left Sidebar (TOC, Settings) -->
                <div id="left-sidebar" class="absolute left-0 top-0 bottom-0 pointer-events-auto flex flex-col">
                    <div id="toc-panel" class="panel-container pointer-events-auto" data-panel="toc">
                        @ReaderTOCPanel(metadata)
                    </div>
                    <div id="settings-panel" class="panel-container pointer-events-auto" data-panel="settings">
                        @ReaderSettingsPanel()
                    </div>
                </div>
                
                <!-- Right Sidebar (Navigator, Bookmarks) -->
                <div id="right-sidebar" class="absolute right-0 top-0 bottom-0 pointer-events-auto flex flex-col">
                    <div id="navigator-panel" class="panel-container pointer-events-auto" data-panel="navigator">
                        @ReaderNavigatorPanel()
                    </div>
                    <div id="bookmarks-panel" class="panel-container pointer-events-auto" data-panel="bookmarks">
                        @ReaderBookmarksPanel(bookmarks)
                    </div>
                </div>
            </div>

            <main id="reader-content" class="w-full h-full">
                <!-- Content loaded by JavaScript based on media type -->
            </main>

            @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="dockable-panel panel-collapsed"
        data-panel="settings"
        data-side="left"
    >
        <div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
            <h3 class="panel-title font-semibold"> Settings</h3>
            <div class="panel-controls flex items-center gap-2">
                <button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
                <button class="window-shade-toggle" data-action="window-shade"></button>
            </div>
        </div>
        <div class="panel-content p-4 overflow-y-auto">
            <!-- 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="dockable-panel"
        data-panel="toc"
        data-side="left"
    >
        <div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
            <h3 class="panel-title font-semibold">📖 Table of Contents</h3>
            <div class="panel-controls flex items-center gap-2">
                <button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
                <button class="window-shade-toggle" data-action="window-shade"></button>
            </div>
        </div>
        <div class="panel-content p-4 overflow-y-auto">
            <nav id="toc-list" class="space-y-2">
                <!-- TOC items populated by JavaScript -->
            </nav>
        </div>
    </div>
}

templ ReaderNavigatorPanel() {
    <div 
        id="navigator-panel" 
        class="dockable-panel"
        data-panel="navigator"
        data-side="right"
    >
        <div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
            <h3 class="panel-title font-semibold">🗺 Navigator</h3>
            <div class="panel-controls flex items-center gap-2">
                <button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
                <button class="window-shade-toggle" data-action="window-shade"></button>
            </div>
        </div>
        <div class="panel-content p-2 overflow-hidden">
            <div id="navigator-viewport" class="relative w-full h-full">
                <!-- Full page preview with draggable viewport box -->
                <!-- JavaScript renders current page as scaled thumbnail with draggable viewport -->
            </div>
        </div>
    </div>
}

templ ReaderBookmarksPanel(bookmarks []Bookmark) {
    <div 
        id="bookmarks-panel" 
        class="dockable-panel panel-collapsed"
        data-panel="bookmarks"
        data-side="right"
    >
        <div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
            <h3 class="panel-title font-semibold">🔖 Bookmarks</h3>
            <div class="panel-controls flex items-center gap-2">
                <button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
                <button class="window-shade-toggle" data-action="window-shade"></button>
            </div>
        </div>
        <div class="panel-content p-4 overflow-y-auto">
            if len(bookmarks) > 0 {
                <div id="bookmarks-list" class="space-y-2">
                    for _, bookmark := range bookmarks {
                        <a 
                            href="#" 
                            data-bookmark-id={ bookmark.ID }
                            class="block py-2 hover:bg-gray-700 rounded px-2"
                        >
                            <span class="font-medium">{ bookmark.Title }</span>
                            <span class="text-xs ml-2" style="color: var(--text-secondary)">
                                { bookmark.Position }
                            </span>
                        </a>
                    }
                </div>
            } else {
                <p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
            }
            <button data-action="add-bookmark" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
                + Add Bookmark
            </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"
    "bookhoard/internal/handlers"
    "bookhoard/internal/services"
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/google/uuid"
    "github.com/jackc/pgx/v5/pgtype"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestReaderEndpoints(t *testing.T) {
    setup := setupTestServer(t)
    defer setup.Teardown(t)

    // Create test user and media item
    ctx := setup.Ctx()
    queries := setup.Queries()

    user := createTestUser(t, ctx, queries)
    admin := createTestAdmin(t, ctx, queries)
    library := createTestLibrary(t, ctx, queries, user.ID)
    mediaItem := createTestMediaItem(t, ctx, queries, library.ID, 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
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)
        req.Header.Set("Accept", "text/html")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)
        assert.Contains(t, resp.Body.String(), mediaItem.Title)
    })

    t.Run("Get Reader Page - No User", func(t *testing.T) {
        // Test 401 without authentication
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
        req.Header.Set("Accept", "text/html")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        // Should redirect to login for HTML requests
        assert.Equal(t, http.StatusFound, resp.Code)
    })

    t.Run("Get Reader Page - API Request", func(t *testing.T) {
        // Test JSON API request returns 401 without auth
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
        req.Header.Set("Accept", "application/json")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusUnauthorized, resp.Code)
    })

    t.Run("Get Page - Lazy Loading", func(t *testing.T) {
        // Test page lazy loading endpoint
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/pages/1", mediaItem.ID), nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        // Should return 200 with page content
        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.Contains(t, result, "content")
        assert.Contains(t, result, "page_number")
    })

    t.Run("Get Chapters", func(t *testing.T) {
        // Test chapter metadata endpoint
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/chapters", mediaItem.ID), nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.Contains(t, result, "chapters")
    })

    t.Run("Get Panels - Grid Detection", func(t *testing.T) {
        // Test panel detection endpoint for comics
        // Create a comic media item
        comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID)

        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/panels/1?method=grid", comicItem.ID), nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.Contains(t, result, "panels")
        assert.Contains(t, result, "detection_method")
    })

    t.Run("Update Panels - Manual Override", func(t *testing.T) {
        // Test manual panel override (all authenticated users)
        comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID)

        panels := []map[string]interface{}{
            {
                "id":            "panel-1",
                "x":             100,
                "y":             50,
                "width":         400,
                "height":        300,
                "reading_order": 1,
            },
        }

        requestBody := map[string]interface{}{
            "panels":            panels,
            "detection_method": "manual",
        }

        bodyBytes, err := json.Marshal(requestBody)
        require.NoError(t, err)

        req := httptest.NewRequest("PUT", fmt.Sprintf("/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes))
        req.Header.Set("Authorization", "Bearer "+accessToken)
        req.Header.Set("Content-Type", "application/json")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err = json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.True(t, result["success"].(bool))
    })

    t.Run("Reading Speed - Get", func(t *testing.T) {
        // Test reading speed retrieval
        req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        // May have zero values if no reading has occurred
        assert.Contains(t, result, "pages_read")
    })

    t.Run("Reading Speed - Update", func(t *testing.T) {
        // Test reading speed update
        requestBody := map[string]interface{}{
            "pages_read":         10,
            "time_spent_minutes": 15.5,
        }

        bodyBytes, err := json.Marshal(requestBody)
        require.NoError(t, err)

        req := httptest.NewRequest("POST", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes))
        req.Header.Set("Authorization", "Bearer "+accessToken)
        req.Header.Set("Content-Type", "application/json")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err = json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.True(t, result["success"].(bool))
    })

    t.Run("Dictionary Lookup", func(t *testing.T) {
        // Test dictionary endpoint
        req := httptest.NewRequest("GET", "/readers/dictionary/example", nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        // Should return 200 or 404 depending on whether word is in cache
        assert.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound)

        if resp.Code == http.StatusOK {
            var result map[string]interface{}
            err := json.Unmarshal(resp.Body.Bytes(), &result)
            require.NoError(t, err)
            assert.Contains(t, result, "word")
            assert.Contains(t, result, "definition")
        }
    })

    t.Run("Settings Management - Get", func(t *testing.T) {
        // Test settings retrieval
        req := httptest.NewRequest("GET", "/readers/settings", nil)
        req.Header.Set("Authorization", "Bearer "+accessToken)

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var result map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        // Should have default settings if never set
        assert.Contains(t, result, "reading_theme")
        assert.Contains(t, result, "font_size")
    })

    t.Run("Settings Management - Update", func(t *testing.T) {
        // Test settings update
        requestBody := map[string]interface{}{
            "reading_theme": "dark",
            "reading_font":  "literata",
            "font_size":     18,
        }

        bodyBytes, err := json.Marshal(requestBody)
        require.NoError(t, err)

        req := httptest.NewRequest("PUT", "/readers/settings", bytes.NewReader(bodyBytes))
        req.Header.Set("Authorization", "Bearer "+accessToken)
        req.Header.Set("Content-Type", "application/json")

        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        // Verify settings were updated
        var result map[string]interface{}
        err = json.Unmarshal(resp.Body.Bytes(), &result)
        require.NoError(t, err)
        assert.Equal(t, "dark", result["reading_theme"])
        assert.Equal(t, "literata", result["reading_font"])
        assert.Equal(t, float64(18), result["font_size"])
    })

    t.Run("Offline Support - Service Worker", func(t *testing.T) {
        // Test service worker registration
        req := httptest.NewRequest("GET", "/static/sw.js", nil)
        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)
        assert.Contains(t, resp.Body.String(), "serviceWorker")
    })

    t.Run("Offline Support - Manifest", func(t *testing.T) {
        // Test PWA manifest
        req := httptest.NewRequest("GET", "/static/manifest.json", nil)
        resp := httptest.NewRecorder()
        setup.ServeHTTP(resp, req)

        assert.Equal(t, http.StatusOK, resp.Code)

        var manifest map[string]interface{}
        err := json.Unmarshal(resp.Body.Bytes(), &manifest)
        require.NoError(t, err)
        assert.Contains(t, manifest, "name")
        assert.Contains(t, manifest, "start_url")
        assert.Contains(t, manifest, "display")
    })
}

// Helper function to create test comic media item
func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *database.Queries, libraryID uuid.UUID, userID uuid.UUID) database.MediaItems {
    mangaType := pgtype.Text{String: "yes", Valid: true}
    readingDirection := pgtype.Text{String: "rtl", Valid: true}

    item, err := queries.CreateMediaItem(ctx, database.CreateMediaItemParams{
        LibraryID:        pgtype.UUID{Bytes: libraryID, Valid: true},
        Title:            "Test Comic",
        FilePath:         "/test/comic.cbz",
        FileSize:         pgtype.Int8{Int64: 1024, Valid: true},
        MimeType:         pgtype.Text{String: "application/vnd.comicbook+zip", Valid: true},
        AddedByAdminID:   pgtype.UUID{Bytes: userID, Valid: true},
        MangaType:        mangaType,
        ReadingDirection: readingDirection,
    })

    require.NoError(t, err)
    return item
}

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)
    • Implement all API endpoints (GetPage, GetChapters, GetPanels, etc.)
    • Stub ParseEbook to return 501 for server-side formats (MOBI/AZW3/DOCX/RTF not implemented in Phase 1)
  • Register reader routes (internal/router/reader.go)
    • API routes: /readers/:id/pages/:n, /readers/:id/chapters, etc.
    • SSR route: /readers/:id (in router/frontend.go)
    • Do NOT register parse route - server-side parsing is Phase 2
  • 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) - EPUB/FB2/TXT/HTML only
  • Create basic comic reader (image display)
  • Integration tests for infrastructure

Phase 1 Code Cleanup:

  • Remove or comment out stub parser functions (lines ~579-1001 in handlers/reader.go):
    • Remove ParseEbook method (returns 501, no route registered)
    • Remove parseMOBI, parseAZW3, parseDOCX, parseRTF stubs
    • Remove helper functions: extractMOBIMetadata, extractMOBIText, parseOPFXML, openFileFromZip, extractDOCXParagraphs, extractDOCXMetadata, convertDOCXToHTML, parseRTFText, convertRTFToHTML, extractRTFMetadata
    • Add TODO comment: "// Phase 2.5: Implement server-side MOBI/AZW3/DOCX/RTF parsers"
  • Result: handlers/reader.go reduced from ~1000 lines to ~577 lines (working code only)
  • File compiles without errors
  • All Phase 1 API endpoints functional

Phase 1 Scope Limitations:

  • Client-side parsing: EPUB, FB2, TXT, HTML (TypeScript parsers)
  • Server-side parsing: MOBI, AZW3, DOCX, RTF (returns "not implemented" error)
  • PDF reader (using pdf.js)
  • Comic/Manga readers (image display)

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 2.5: Server-Side Parsers (Week 5)

Complete hybrid parsing strategy - add support for complex formats

  • Implement ParseEbook endpoint: POST /readers/:id/parse
    • Register route in router/reader.go
    • Route to parser based on MIME type
  • Implement MOBI parser (parseMOBI handler)
    • Parse MOBI header and metadata
    • Extract text content (handle Huffman compression)
    • Convert to CIF (HTML structure)
    • Unit tests for MOBI parsing
  • Implement AZW3/KF8 parser (parseAZW3 handler)
    • Parse AZW3 container (ZIP-based)
    • Extract EPUB3 content from KF8 container
    • Convert to CIF
    • Unit tests for AZW3 parsing
  • Implement DOCX parser (parseDOCX handler)
    • Parse DOCX ZIP structure
    • Extract document.xml for paragraphs
    • Convert formatting to HTML
    • Convert to CIF
    • Unit tests for DOCX parsing
  • Implement RTF parser (parseRTF handler)
    • Parse RTF markup
    • Extract text and formatting
    • Convert to HTML/CIF
    • Unit tests for RTF parsing
  • Integration tests for all server-side parsers
  • Update TypeScript parser-manager.ts to call parse endpoint
  • Update requiresServerParsing() to detect MOBI/AZW3/DOCX/RTF

Note: These formats are complex and require specialized parsing libraries. Consider using:

  • Go's archive/zip for ZIP-based formats (AZW3, DOCX)
  • Third-party libraries for MOBI decompression
  • Custom RTF parser (spec is public)

Phase 3: Advanced Features (Week 6-7)

  • 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/get-reader-page.bru

meta:
  name: Get Reader Page
  type: http
  seq: 1
http:
  method: GET
  url: '{{base_url}}/readers/{{media_item_id}}'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Reader Page
  
  Returns the reader page for a specific media item.
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  
  **Response:** HTML reader page
  
  **Error Responses:**
  - 401: Invalid authentication
  - 404: Media item not found
  - 403: User does not have access to this library

File: bruno/reader/get-page.bru

meta:
  name: Get Book Page
  type: http
  seq: 2
http:
  method: GET
  url: '{{base_url}}/readers/{{media_item_id}}/pages/{{page_number}}'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Book Page

  Retrieves a specific page of the book for rendering.

  **Authentication:** Required (Bearer token)

  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  - `page_number` (number): Page number to retrieve

  **Query Parameters:**
  - `format` (string, optional): Response format ('html', 'text', 'json')

  **Response:**
  - `content` (string): Page content (HTML or text)
  - `page_number` (number): Current page number
  - `total_pages` (number): Total pages in book
  - `chapter_title` (string, optional): Current chapter title

  **Error Responses:**
  - 401: Invalid authentication
  - 404: Page not found

File: bruno/reader/get-chapters.bru

meta:
  name: Get Chapters
  type: http
  seq: 3
http:
  method: GET
  url: '{{base_url}}/readers/{{media_item_id}}/chapters'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Chapters
  
  Retrieves the table of contents/chapter list for a book.
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  
  **Response:**
  ```json
  {
    "chapters": [
      {
        "id": "chapter-1",
        "title": "Chapter 1",
        "start_page": 1,
        "page_count": 25,
        "level": 1
      }
    ]
  }

Error Responses:

  • 401: Invalid authentication
  • 404: Media item not found

**File:** `bruno/reader/get-panels.bru`

```yaml
meta:
  name: Get Comic Panels
  type: http
  seq: 4
http:
  method: GET
  url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Comic Panels
  
  Retrieves panel detection data for a comic/manga page.
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  - `page_number` (number): Page number
  
  **Query Parameters:**
  - `method` (string, optional): Detection method ('grid', 'ml', 'manual')
  
  **Response:**
  ```json
  {
    "page_number": 1,
    "detection_method": "ml",
    "panels": [
      {
        "id": "panel-1",
        "x": 100,
        "y": 50,
        "width": 400,
        "height": 300,
        "reading_order": 1
      }
    ]
  }

Error Responses:

  • 401: Invalid authentication
  • 404: Page not found

**File:** `bruno/reader/update-panels.bru`

```yaml
meta:
  name: Update Comic Panels
  type: http
  seq: 5
http:
  method: PUT
  url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}'
  auth: inherit
  body:
    type: json
    json: {
      "panels": [
        {
          "id": "panel-1",
          "x": 100,
          "y": 50,
          "width": 400,
          "height": 300,
          "reading_order": 1
        }
      ],
      "detection_method": "manual"
    }
docs: |-
  ## Update Comic Panels
  
  Updates panel detection data (manual override).
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  - `page_number` (number): Page number
  
  **Request Body:**
  ```json
  {
    "panels": [...],
    "detection_method": "manual"
  }

Response:

{
  "success": true,
  "message": "Panels updated successfully"
}

Error Responses:

  • 401: Invalid authentication
  • 400: Invalid panel data

**File:** `bruno/reader/get-reading-speed.bru`

```yaml
meta:
  name: Get Reading Speed
  type: http
  seq: 6
http:
  method: GET
  url: '{{base_url}}/readers/{{media_item_id}}/reading-speed'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Reading Speed
  
  Retrieves reading speed statistics for a book.
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  
  **Response:**
  ```json
  {
    "words_per_minute": 250,
    "pages_per_minute": 1.5,
    "pages_read": 50,
    "total_reading_minutes": 33.3,
    "last_read_at": "2024-01-15T10:30:00Z"
  }

Error Responses:

  • 401: Invalid authentication
  • 404: Media item not found

**File:** `bruno/reader/update-reading-speed.bru`

```yaml
meta:
  name: Update Reading Speed
  type: http
  seq: 7
http:
  method: POST
  url: '{{base_url}}/readers/{{media_item_id}}/reading-speed'
  auth: inherit
  body:
    type: json
    json: {
      "pages_read": 10,
      "time_spent_minutes": 15.5
    }
docs: |-
  ## Update Reading Speed
  
  Updates reading speed statistics.
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `media_item_id` (string): Media Item UUID
  
  **Request Body:**
  ```json
  {
    "pages_read": 10,
    "time_spent_minutes": 15.5
  }

Response:

{
  "success": true,
  "words_per_minute": 250,
  "pages_per_minute": 1.5
}

Error Responses:

  • 401: Invalid authentication
  • 400: Invalid data

**File:** `bruno/reader/lookup-word.bru`

```yaml
meta:
  name: Dictionary Lookup
  type: http
  seq: 8
http:
  method: GET
  url: '{{base_url}}/readers/dictionary/{{word}}'
  auth: inherit
  body:
    type: none
docs: |-
  ## Dictionary Lookup
  
  Looks up a word in the dictionary (offline-capable).
  
  **Authentication:** Required (Bearer token)
  
  **Path Parameters:**
  - `word` (string): Word to look up
  
  **Query Parameters:**
  - `lang` (string, optional): Language code (default: 'en')
  
  **Response:**
  ```json
  {
    "word": "example",
    "definition": "A representative form or pattern.",
    "part_of_speech": "noun",
    "example": "This is an example sentence.",
    "etymology": "From Latin exemplum"
  }

Error Responses:

  • 401: Invalid authentication
  • 404: Word not found

**File:** `bruno/reader/get-settings.bru`

```yaml
meta:
  name: Get Reader Settings
  type: http
  seq: 9
http:
  method: GET
  url: '{{base_url}}/readers/settings'
  auth: inherit
  body:
    type: none
docs: |-
  ## Get Reader Settings
  
  Retrieves user's reader settings.
  
  **Authentication:** Required (Bearer token)
  
  **Response:**
  ```json
  {
    "chrome_behavior": "auto-hide",
    "progress_mode": "pages",
    "chrome_theme": "tokyo-night",
    "reading_theme": "dark",
    "reading_font": "literata",
    "font_size": 16,
    "line_height": 1.6,
    "margin_width": 20
  }

Error Responses:

  • 401: Invalid authentication

**File:** `bruno/reader/update-settings.bru`

```yaml
meta:
  name: Update Reader Settings
  type: http
  seq: 10
http:
  method: PUT
  url: '{{base_url}}/readers/settings'
  auth: inherit
  body:
    type: json
    json: {
      "reading_theme": "dark",
      "reading_font": "literata",
      "font_size": 18
    }
docs: |-
  ## Update Reader Settings
  
  Updates user's reader settings (partial update supported).
  
  **Authentication:** Required (Bearer token)
  
  **Request Body:** Partial settings object (only include fields to update)
  ```json
  {
    "reading_theme": "dark",
    "reading_font": "literata",
    "font_size": 18
  }

Response: Updated settings object (same format as GET)

Error Responses:

  • 401: Invalid authentication
  • 400: Invalid setting value

---

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