Files
bookhoard/READER_IMPLEMENTATION_PLAN.md
T
john-okeefe 9f7dffd292 docs: add comprehensive reader implementation plan
Add detailed 8-week implementation plan for ebook, comic, and manga reader
with hybrid architecture, panel detection, offline support, and libre fonts.

Key Features:
- Hybrid reader architecture: Shared infrastructure + specialized components
- Ebook reader: EPUB parsing, HTML rendering, CFI navigation, typography engine
- Comic reader: Grid-based + ML + manual panel detection, zoom animations
- Manga reader: RTL navigation, vertical scroll mode
- Offline support: PWA with service worker, cached pages
- Dictionary: Offline-capable word lookup
- Annotations: Bookmarks, highlights, notes (reuses existing DB schema)
- Reading statistics: Speed tracking, time estimation
- KOReader-style progress: Pages, chapter-relative, percentage, time-left

Theming Strategy:
- UI Chrome: All 11 Bookhoard themes
- Ebook text: 5 reading-optimized themes (light, sepia, dark, night, high-contrast)
- Comics/manga: All 11 themes (visual content works well with colors)

Typography:
- 8 bundled libre fonts (~1.2MB WOFF2): Literata, Crimson Text,
  Source Serif 4, EB Garamond, Libertinus, Noto Serif, Charis SIL, IBM Plex
- Standard weights only (400, 400i, 700, 700i)
- Font acquisition script included
- Offline-ready (no network requests for fonts)

Database Schema:
- panel_data: Hybrid panel detection (grid/ML/manual)
- reading_speed: Per-user reading speed tracking
- dictionary_cache: Offline dictionary support
- reader_settings: Per-user preferences (DB + localStorage fallback)

API Endpoints:
- 10 new reader routes (/api/readers/*)
- Lazy-loading page cache (5-page ahead)
- WebSocket integration for real-time sync
- Bruno API tests included

Phased Timeline:
- Week 1-2: Infrastructure & basic reader
- Week 3-4: Comic/manga features
- Week 5-6: Advanced features
- Week 7: Offline support
- Week 8: Polish & testing

Code Reuse:
- Reuses existing WebSocket sync, progress tracking, annotations
- Surgical code edits (extend, don't duplicate)
- Follows existing Bookhoard patterns and conventions

Includes:
- Database schema changes (SQL)
- API endpoint specifications
- TypeScript type definitions
- Frontend architecture (SSR-first with Alpine.js)
- Integration test patterns
- Bruno OpenCollection YAML tests
- User and developer documentation locations

Estimated total implementation time: 8 weeks
2026-04-01 21:10:34 -04:00

113 KiB
Raw Blame History

📖 Bookhoard Reader Implementation Plan

Overview

Build a modern, responsive web reader for ebooks, comics, and manga with full feature parity across all three media types.

Design Philosophy:

  • Hybrid architecture: Shared infrastructure + specialized reader components
  • Surgical code reuse: Leverage existing WebSocket sync, progress tracking, annotation systems
  • Progressive enhancement: SSR-first with JavaScript enhancements
  • Privacy-first: Per-user settings with localStorage fallback
  • Offline-capable: PWA with offline dictionary

1. Architecture

1.1 Component Structure

Reader Infrastructure (Shared)
├── ReaderShell.ts          - UI shell, chrome control, routing
├── ProgressTracker.ts      - Integration with existing reading_progress table
├── AnnotationManager.ts    - Integration with existing notes/highlights tables
├── WebSocketSync.ts        - Reuse existing sync system
├── SettingsManager.ts      - Per-user preferences (DB + localStorage)
├── BookmarkManager.ts      - Integration with existing bookmarks
└── ChapterDetector.ts      - Chapter detection for all media types

Medium-Specific Readers
├── EbookReader/
│   ├── EPUBParser.ts       - Custom EPUB parsing (ZIP + XML)
│   ├── HTMLRenderer.ts     - Browser-native HTML rendering
│   ├── CFINavigator.ts     - EPUB CFI navigation (reuse existing sync/format.go logic)
│   ├── TypographyEngine.ts - Font rendering, theme integration
│   └── DictionaryPopup.ts  - Offline dictionary lookup
│
├── ComicReader/
│   ├── ImageArchiveParser.ts  - CBZ/CBR/PDF parsing
│   ├── CanvasRenderer.ts      - Canvas rendering with lazy loading
│   ├── PanelDetector.ts       - Grid-based + ML + manual override
│   ├── PanelNavigator.ts      - Panel zoom with smooth animations
│   └── PageCache.ts           - 5-page ahead cache
│
└── MangaReader/ (extends ComicReader)
    ├── RTLNavigator.ts        - Right-to-left navigation
    ├── VerticalScrollMode.ts  - Webtoon-style vertical scroll
    └── PanelDetector.ts       - Manga-aware panel detection

1.2 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)                        │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 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.3 Data Flow

User opens reader
    ↓
Backend: GET /api/readers/:mediaItemId
    ↓
Verify access, fetch metadata, progress, bookmarks
    ↓
SSR render: templates/reader.templ with initial data
    ↓
Frontend: Initialize appropriate reader (Ebook/Comic/Manga)
    ↓
Load content (lazy load + cache)
    ↓
User interacts (turn page, highlight, bookmark)
    ↓
Real-time sync via WebSocket (reuse existing system)

2. Database Schema Changes

2.1 New Tables

-- Panel detection data
CREATE TABLE panel_data (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
  page_number INTEGER NOT NULL,
  detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
  panels JSONB NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(media_item_id, page_number)
);

CREATE INDEX idx_panel_data_media_item ON panel_data(media_item_id);

-- Reading speed tracking
CREATE TABLE reading_speed (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
  words_per_minute DECIMAL(6,2),
  pages_per_minute DECIMAL(6,2),
  pages_read INTEGER DEFAULT 0,
  total_reading_minutes DECIMAL(8,2) DEFAULT 0,
  last_read_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, media_item_id)
);

CREATE INDEX idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX idx_reading_speed_item ON reading_speed(media_item_id);

-- Dictionary cache (for offline use)
CREATE TABLE dictionary_cache (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  word VARCHAR(100) NOT NULL UNIQUE,
  definition TEXT NOT NULL,
  part_of_speech VARCHAR(20),
  example TEXT,
  etymology TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  accessed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_dictionary_word ON dictionary_cache(word);

-- Reader settings (per-user preferences)
CREATE TABLE reader_settings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  setting_key VARCHAR(50) NOT NULL,
  setting_value JSONB NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, setting_key)
);

CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);

2.2 Alter Existing Tables

-- Add chapter metadata to media_items
ALTER TABLE media_items
ADD COLUMN chapter_metadata JSONB;

-- Example structure:
-- {
--   "chapters": [
--     {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20},
--     {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25}
--   ]
-- }

-- Note: reading_progress table already exists with epubcfi, page, percentage fields
-- Note: notes and highlights tables already exist
-- Note: bookmarks table already exists

2.3 Schema.sql Implementation

File: database/schema/schema.sql

Add the above tables to the schema file. Follow existing patterns:

  • Use gen_random_uuid() for UUID defaults
  • Use TIMESTAMPTZ DEFAULT NOW() for timestamps
  • Add appropriate indexes for foreign keys
  • Use ON DELETE CASCADE for referential integrity

3. API Endpoints

3.1 Reader Routes

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

package router

func registerReaderRoutes(cfg *Config) {
    e := cfg.Echo
    jwtMiddleware := createJWTMiddleware(cfg)
    reader := e.Group("/readers", jwtMiddleware)

    // Reader page (SSR)
    reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader)

    // Content serving (lazy-loaded pages)
    reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)

    // Chapter metadata
    reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)

    // Panel data
    reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
    reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) // Manual override

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

3.2 Handler Implementation

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

Follow existing patterns from media.go and auth.go:

  • Use database.Queries for all DB operations
  • Return JSON responses with consistent structure
  • Handle errors properly (404, 403, 500)
  • Support content negotiation (JSON for API, HTML for SSR)

Key handler signatures:

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

func (h *ReaderHandler) ShowReader(c echo.Context) error
func (h *ReaderHandler) GetPage(c echo.Context) error
func (h *ReaderHandler) GetChapters(c echo.Context) error
func (h *ReaderHandler) GetPanels(c echo.Context) error
func (h *ReaderHandler) UpdatePanels(c echo.Context) error
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error
func (h *ReaderHandler) LookupWord(c echo.Context) error
func (h *ReaderHandler) GetSettings(c echo.Context) error
func (h *ReaderHandler) UpdateSettings(c echo.Context) error

3.3 Service Layer

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

All business logic goes here, not in handlers:

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

// Chapter detection for all media types
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error)

// Panel detection (grid-based, ML, manual)
func (s *ReaderService) DetectPanels(ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string) ([]Panel, error)

// Reading speed calculation
func (s *ReaderService) CalculateReadingSpeed(ctx context.Context, userID, mediaItemID uuid.UUID, pagesRead int, minutes float64) error

// Dictionary lookup (with cache)
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error)

// Settings management (DB + localStorage sync)
func (s *ReaderService) GetSettings(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error)
func (s *ReaderService) UpdateSettings(ctx context.Context, userID uuid.UUID, settings map[string]interface{}) error

4. Frontend Implementation

4.1 File Structure

web/src/reader/
├── reader.ts                 - Main reader entry point
├── reader-shell.ts           - UI shell, chrome control
├── progress-indicator.ts     - KOReader-style switchable progress
├── settings-manager.ts       - Settings (DB + localStorage)
├── slide-in-panel.ts         - Shared slide-in panel (TOC + Settings)
├── annotation-manager.ts     - Highlights, notes, bookmarks
├── websocket-sync.ts         - Reuse existing websocket.ts
├── dictionary-popup.ts       - Offline dictionary lookup
│
├── ebook/
│   ├── epub-parser.ts        - EPUB parsing (ZIP + XML)
│   ├── html-renderer.ts      - Browser-native rendering
│   ├── cfi-navigator.ts      - EPUB CFI navigation
│   ├── typography-engine.ts  - Font rendering, themes
│   └── chapter-detector.ts   - Chapter detection
│
├── comic/
│   ├── image-parser.ts       - CBZ/CBR/PDF parsing
│   ├── canvas-renderer.ts    - Canvas rendering
│   ├── panel-detector.ts     - Grid + ML + manual
│   ├── panel-navigator.ts    - Panel zoom animations
│   └── page-cache.ts         - 5-page ahead cache
│
└── manga/
    ├── rtl-navigator.ts      - Right-to-left navigation
    └── vertical-scroll.ts    - Webtoon-style scroll

4.2 TypeScript Types

File: web/src/types/reader.d.ts (new file)

// Reader metadata (from API)
interface ReaderMetadata {
  media_item_id: string;
  title: string;
  author: string;
  cover_image_path: string;
  library_type: 'ebook' | 'comic' | 'manga';
  mime_type: string;
  file_path: string;
  chapter_metadata?: ChapterMetadata;
  total_pages?: number;
}

// Chapter metadata
interface ChapterMetadata {
  chapters: Chapter[];
}

interface Chapter {
  id: string;
  title: string;
  start_page: number;
  page_count: number;
}

// Panel data
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;          // percentage (0-100)
  y: number;          // percentage (0-100)
  width: number;      // percentage (0-100)
  height: number;     // percentage (0-100)
  reading_order: number;
}

// Reading speed
interface ReadingSpeed {
  words_per_minute: number;
  pages_per_minute: number;
  pages_read: number;
  total_reading_minutes: number;
  last_read_at: string;
}

// Dictionary entry
interface DictionaryEntry {
  word: string;
  definition: string;
  part_of_speech?: string;
  example?: string;
  etymology?: string;
}

// Reader settings
interface ReaderSettings {
  // Display
  chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll';
  progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left';

  // THEMING (Hybrid Approach)
  // Chrome theme: Applied to reader UI (bars, panels, settings)
  // Options: All 11 Bookhoard themes (tokyo-night, dracula, etc.)
  chrome_theme: string;

  // Reading theme: Applied to ebook text content only
  // Options: Reading-optimized themes (light, sepia, dark, night, high-contrast)
  // Comic/manga: Use chrome_theme (all 11 themes work well with visual content)
  reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';

  // TYPOGRAPHY (Ebook Reading Fonts)
  // 8 bundled libre fonts optimized for extended reading
  // Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i)
  // UI elements use Bookhoard's existing font stack (not these reading fonts)
  reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
  font_size: number;
  line_height: number;
  margin_width: number;

  // Navigation
  tap_zone_size: number;  // Percentage (0-100)
  auto_scroll: boolean;
  panel_zoom_enabled: boolean;

  // Manga/Comic specific
  double_page_spread: boolean;
  reading_direction: 'ltr' | 'rtl' | 'vertical';

  // Advanced
  hardware_acceleration: boolean;
}

// Progress indicator (KOReader-style)
interface ProgressDisplay {
  mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
  current: number;
  total: number;
  label?: string;  // e.g., "Chapter 5"
  time_left?: string;  // e.g., "2h 15m"
}

4.3 Reader Shell

File: web/src/reader/reader-shell.ts

// Shared reader infrastructure
// Implements chrome control, routing, settings sync

import { Alpine } from "../alpine";
import { getReaderMetadata, updateReadingProgress } from "./api";
import { SettingsManager } from "./settings-manager";
import { ProgressIndicator } from "./progress-indicator";

let currentReader: EbookReader | ComicReader | MangaReader | null = null;

function initializeReader(): void {
  const mediaItemId = document.body.dataset.mediaItemId;
  if (!mediaItemId) return;

  // Fetch metadata
  getReaderMetadata(mediaItemId).then((metadata) => {
    // Initialize appropriate reader based on type
    switch (metadata.library_type) {
      case 'ebook':
        currentReader = new EbookReader(metadata);
        break;
      case 'comic':
        currentReader = new ComicReader(metadata);
        break;
      case 'manga':
        currentReader = new MangaReader(metadata);
        break;
    }

    currentReader?.initialize();
  });
}

// Chrome control
function toggleChrome(): void {
  const chrome = document.getElementById('reader-chrome');
  chrome?.classList.toggle('hidden');
}

function setChromeBehavior(behavior: ReaderSettings['chrome_behavior']): void {
  // Auto-hide, always-visible, or hide-on-scroll
}

// Export for Alpine integration
Alpine.data('readerShell', () => ({
  init() {
    initializeReader();
  }
}));

4.4 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.5 Settings Manager (DB + localStorage)

File: web/src/reader/settings-manager.ts

// Per-user settings with localStorage fallback

import { apiGet, apiPut } from "../api";
import { getToken, setItem, getItem } from "../storage";

const SETTINGS_KEY = 'reader_settings';
const LOCALSTORAGE_KEY = 'reader_settings_local';

interface SettingsManager {
  load(): Promise<ReaderSettings>;
  save(settings: Partial<ReaderSettings>): Promise<void>;
  sync(): Promise<void>;  // Sync localStorage → DB
  get(key: keyof ReaderSettings): any;
  set(key: keyof ReaderSettings, value: any): Promise<void>;
}

async function loadSettings(): Promise<ReaderSettings> {
  const token = getToken();
  if (!token) {
    // Fallback to localStorage
    const local = getItem(LOCALSTORAGE_KEY);
    return local ? JSON.parse(local) : getDefaultSettings();
  }

  try {
    const response = await apiGet('/readers/settings');
    const settings = await response.json();
    // Cache in localStorage
    setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
    return settings;
  } catch (error) {
    // Fallback to localStorage on error
    const local = getItem(LOCALSTORAGE_KEY);
    return local ? JSON.parse(local) : getDefaultSettings();
  }
}

async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
  const token = getToken();
  if (!token) {
    // Save to localStorage only
    const current = loadSettings();
    const updated = { ...current, ...settings };
    setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
    return;
  }

  try {
    await apiPut('/readers/settings', settings);
    // Update localStorage cache
    const current = loadSettings();
    const updated = { ...current, ...settings };
    setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
  } catch (error) {
    // Fallback to localStorage
    const current = loadSettings();
    const updated = { ...current, ...settings };
    setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
  }
}

function getDefaultSettings(): ReaderSettings {
  return {
    chrome_behavior: 'auto-hide',
    progress_mode: 'pages',
    chrome_theme: 'tokyo-night',      // UI chrome: All 11 themes available
    reading_theme: 'dark',            // Ebook text: 5 reading-optimized themes
    reading_font: 'literata',         // Default reading font (designed for ebooks)
    tap_zone_size: 30,
    auto_scroll: false,
    panel_zoom_enabled: true,
    font_size: 16,
    line_height: 1.6,
    margin_width: 20,
    double_page_spread: false,
    reading_direction: 'ltr',
    hardware_acceleration: true
  };
}

5. Ebook Reader Implementation

5.1 EPUB Parser

File: web/src/reader/ebook/epub-parser.ts

// EPUB parsing - ZIP + XML parsing for EPUB 2.0 and 3.0

interface EPUBMetadata {
  title: string;
  author: string;
  language: string;
  publisher?: string;
  description?: string;
  identifier?: string;  // ISBN, UUID, etc.
}

interface EPUBSpineItem {
  id: string;
  href: string;
  linear: string;  // "yes" or "no"
  properties?: string;
}

interface EPUBTableOfContents {
  id: string;
  label: string;
  href: string;
  children: EPUBTableOfContents[];
}

interface EPUBPackage {
  metadata: EPUBMetadata;
  spine: EPUBSpineItem[];
  toc: EPUBTableOfContents[];
  resources: Map<string, Blob>;  // All files (HTML, CSS, images, fonts)
  coverImage?: Blob;
}

class EPUBParser {
  private zip: JSZip | null = null;
  private packageDoc: XMLDocument | null = null;

  async parse(epubBlob: Blob): Promise<EPUBPackage> {
    // EPUB is a ZIP file
    const zip = new JSZip();
    this.zip = await zip.loadAsync(epubBlob);

    // Parse container.xml to find OPF file
    const containerXml = await this.getFileContent('META-INF/container.xml');
    const containerDoc = this.parseXML(containerXml);
    const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path');

    if (!opfPath) {
      throw new Error('Invalid EPUB: no OPF file found');
    }

    // Parse OPF file
    const opfXml = await this.getFileContent(opfPath);
    this.packageDoc = this.parseXML(opfXml);

    // Extract metadata
    const metadata = this.parseMetadata();

    // Parse spine (reading order)
    const spine = this.parseSpine();

    // Parse table of contents
    const toc = await this.parseTOC(opfPath);

    // Load all resources
    const resources = await this.loadResources();

    // Extract cover image
    const coverImage = await this.extractCover();

    return {
      metadata,
      spine,
      toc,
      resources,
      coverImage
    };
  }

  private async getFileContent(path: string): Promise<string> {
    if (!this.zip) throw new Error('EPUB not loaded');

    const file = this.zip.file(path);
    if (!file) {
      throw new Error(`File not found: ${path}`);
    }

    return file.async('text');
  }

  private parseXML(xmlString: string): XMLDocument {
    const parser = new DOMParser();
    return parser.parseFromString(xmlString, 'text/xml');
  }

  private parseMetadata(): EPUBMetadata {
    if (!this.packageDoc) throw new Error('OPF not loaded');

    const metadata = this.packageDoc.querySelector('metadata');
    if (!metadata) {
      throw new Error('No metadata found in OPF');
    }

    const title = metadata.querySelector('title')?.textContent || '';
    const author = metadata.querySelector('creator')?.textContent || '';
    const language = metadata.querySelector('language')?.textContent || 'en';
    const publisher = metadata.querySelector('publisher')?.textContent || undefined;
    const description = metadata.querySelector('description')?.textContent || undefined;
    const identifier = metadata.querySelector('identifier')?.textContent || undefined;

    return { title, author, language, publisher, description, identifier };
  }

  private parseSpine(): EPUBSpineItem[] {
    if (!this.packageDoc) throw new Error('OPF not loaded');

    const spine = this.packageDoc.querySelector('spine');
    if (!spine) {
      throw new Error('No spine found in OPF');
    }

    const manifest = this.packageDoc.querySelector('manifest');
    if (!manifest) {
      throw new Error('No manifest found in OPF');
    }

    const items: EPUBSpineItem[] = [];
    const spineItems = spine.querySelectorAll('itemref');

    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');
      const linear = itemref.getAttribute('linear') || 'yes';
      const properties = itemref.getAttribute('properties') || undefined;

      if (href) {
        items.push({ id: idref, href, linear, properties });
      }
    });

    return items;
  }

  private async parseTOC(opfPath: string): Promise<EPUBTableOfContents[]> {
    if (!this.packageDoc) throw new Error('OPF not loaded');

    // Try EPUB 3.0 navigation document first
    const navItem = this.packageDoc.querySelector('manifest item[properties~="nav"]');
    if (navItem) {
      const navHref = navItem.getAttribute('href');
      if (navHref) {
        const navPath = this.resolvePath(opfPath, navHref);
        return this.parseNavTOC(navPath);
      }
    }

    // Fallback to EPUB 2.0 NCX
    const ncxId = this.packageDoc.querySelector('spine')?.getAttribute('toc');
    if (ncxId) {
      const ncxItem = this.packageDoc.querySelector(`manifest [id="${ncxId}"]`);
      if (ncxItem) {
        const ncxHref = ncxItem.getAttribute('href');
        if (ncxHref) {
          const ncxPath = this.resolvePath(opfPath, ncxHref);
          return this.parseNCXTOC(ncxPath);
        }
      }
    }

    return [];
  }

  private async parseNavTOC(navPath: string): Promise<EPUBTableOfContents[]> {
    const navXml = await this.getFileContent(navPath);
    const navDoc = this.parseXML(navXml);
    const nav = navDoc.querySelector('nav');

    if (!nav) return [];

    const items: EPUBTableOfContents[] = [];
    const ol = nav.querySelector('ol');

    if (ol) {
      const lis = ol.querySelectorAll(':scope > li');
      for (const li of lis) {
        const link = li.querySelector('a');
        if (link) {
          const label = link.textContent || '';
          const href = link.getAttribute('href') || '';
          items.push({ id: href, label, href, children: [] });
        }
      }
    }

    return items;
  }

  private async parseNCXTOC(ncxPath: string): Promise<EPUBTableOfContents[]> {
    const ncxXml = await this.getFileContent(ncxPath);
    const ncxDoc = this.parseXML(ncxXml);
    const navMap = ncxDoc.querySelector('navMap');

    if (!navMap) return [];

    return this.parseNCXNode(navMap);
  }

  private parseNCXNode(node: Element): EPUBTableOfContents[] {
    const items: EPUBTableOfContents[] = [];
    const navPoints = node.querySelectorAll(':scope > navPoint');

    navPoints.forEach((navPoint) => {
      const label = navPoint.querySelector('navLabel text')?.textContent || '';
      const content = navPoint.querySelector('content');
      const href = content?.getAttribute('src') || '';
      const id = href;

      const children = this.parseNCXNode(navPoint);

      items.push({ id, label, href, children });
    });

    return items;
  }

  private async loadResources(): Promise<Map<string, Blob>> {
    const resources = new Map<string, Blob>();

    if (!this.zip) return resources;

    // Load all files from the ZIP
    const files = Object.keys(this.zip.files);

    for (const path of files) {
      const file = this.zip.file(path);
      if (file && !file.dir) {
        const blob = await file.async('blob');
        resources.set(path, blob);
      }
    }

    return resources;
  }

  private async extractCover(): Promise<Blob | undefined> {
    if (!this.packageDoc) return undefined;

    // Try cover-id metadata
    const coverId = this.packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content');
    if (coverId) {
      const coverItem = this.packageDoc.querySelector(`manifest [id="${coverId}"]`);
      if (coverItem) {
        const coverHref = coverItem.getAttribute('href');
        if (coverHref && this.zip) {
          const coverFile = this.zip.file(coverHref);
          if (coverFile) {
            return coverFile.async('blob');
          }
        }
      }
    }

    // Fallback: look for cover image in manifest
    const coverItem = this.packageDoc.querySelector('manifest item[properties~="cover-image"]');
    if (coverItem) {
      const coverHref = coverItem.getAttribute('href');
      if (coverHref && this.zip) {
        const coverFile = this.zip.file(coverHref);
        if (coverFile) {
          return coverFile.async('blob');
        }
      }
    }

    return undefined;
  }

  private resolvePath(basePath: string, relativePath: string): string {
    const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
    return baseDir + relativePath;
  }

  // Get a specific spine item as HTML document
  async getSpineItem(spinItem: EPUBSpineItem): Promise<HTMLDocument> {
    if (!this.zip) throw new Error('EPUB not loaded');

    const content = await this.getFileContent(spinItem.href);
    const parser = new DOMParser();
    const doc = parser.parseFromString(content, 'text/html');

    // Inject base URL for resolving relative links
    const base = doc.createElement('base');
    base.href = spinItem.href;
    doc.head.insertBefore(base, doc.head.firstChild);

    return doc;
  }
}

5.2 HTML Renderer

File: web/src/reader/ebook/html-renderer.ts

// HTML rendering with theme support, font loading, and image handling

interface RendererConfig {
  readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';  // Reading-optimized themes
  readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';  // Bundled libre fonts
  fontSize: number;
  lineHeight: number;
  marginWidth: number;
  textAlign: 'left' | 'justify';
  columnCount: 1 | 2;  // Single or double column
}

class HTMLRenderer {
  private container: HTMLElement;
  private config: RendererConfig;
  private loadedFonts: Set<string> = new Set();

  constructor(container: HTMLElement, config: RendererConfig) {
    this.container = container;
    this.config = config;
  }

  async renderDocument(doc: HTMLDocument): Promise<void> {
    // Apply theme
    this.applyTheme();

    // Apply typography settings
    this.applyTypography();

    // Inject custom styles for reader
    this.injectReaderStyles();

    // Handle embedded fonts
    await this.loadEmbeddedFonts(doc);

    // Handle images
    this.processImages(doc);

    // Clear container and append content
    this.container.innerHTML = '';
    this.container.appendChild(doc.body);

    // Apply column layout
    this.applyColumnLayout();
  }

  private applyTheme(): void {
    // IMPORTANT: Hybrid theming approach
    //
    // UI Chrome (reader shell, bars, panels): All 11 Bookhoard themes
    // Ebook text content: Reading-optimized themes only (5 options)
    // Comics/manga: All 11 Bookhoard themes (visual content works well with colors)
    //
    // Why? Long-form reading requires eye-comfort optimization.
    // Popular ebook readers (Kindle, Kobo, Apple Books) offer 3-5 reading themes,
    // not unlimited colors. Purple text for 300 pages causes eye fatigue.

    // Reading-optimized themes (ebooks only)
    const readingThemes: Record<string, Record<string, string>> = {
      'light': {
        '--bg-primary': '#ffffff',
        '--text-primary': '#1a1a1a',
        '--text-secondary': '#666666',
        '--accent': '#0066cc'
      },
      'sepia': {
        '--bg-primary': '#f4ecd8',
        '--text-primary': '#5f4b32',
        '--text-secondary': '#8b7355',
        '--accent': '#8b4513'
      },
      'dark': {
        '--bg-primary': '#1a1b26',
        '--text-primary': '#c0caf5',
        '--text-secondary': '#565f89',
        '--accent': '#7aa2f7'
      },
      'night': {
        '--bg-primary': '#0d1117',
        '--text-primary': '#c9d1d9',
        '--text-secondary': '#8b949e',
        '--accent': '#58a6ff'
      },
      'high-contrast': {
        '--bg-primary': '#000000',
        '--text-primary': '#ffffff',
        '--text-secondary': '#cccccc',
        '--accent': '#ffff00'
      }
    };

    const theme = readingThemes[this.config.readingTheme] || readingThemes['dark'];

    for (const [key, value] of Object.entries(theme)) {
      this.container.style.setProperty(key, value);
    }
  }

  private applyTypography(): void {
    const style = document.createElement('style');

    // Get font stack for selected reading font
    const fontStack = getFontStack(this.config.readingFont);

    style.textContent = `
      .ebook-content {
        font-family: ${fontStack};
        font-size: ${this.config.fontSize}px;
        line-height: ${this.config.lineHeight};
        text-align: ${this.config.textAlign};
        padding: 0 ${this.config.marginWidth}px;
        max-width: 100%;
        overflow-wrap: break-word;
      }

      .ebook-content p {
        margin-bottom: 1em;
        text-indent: ${this.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);
      }
    `;

    this.container.appendChild(style);
  }

  private injectReaderStyles(): void {
    // Add ARIA roles for accessibility
    this.container.setAttribute('role', 'main');
    this.container.setAttribute('aria-label', 'Book content');
  }

  private async loadEmbeddedFonts(doc: HTMLDocument): Promise<void> {
    // Find @font-face rules in document
    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 this.loadFont(fontUrl);
        }
      }
    }
  }

  private async loadFont(fontUrl: string): Promise<void> {
    if (this.loadedFonts.has(fontUrl)) return;

    try {
      const fontFace = new FontFace('custom-font', `url(${fontUrl})`);
      await fontFace.load();
      document.fonts.add(fontFace);
      this.loadedFonts.add(fontUrl);
    } catch (error) {
      console.error('Failed to load font:', fontUrl, error);
    }
  }

  private processImages(doc: HTMLDocument): void {
    const images = doc.querySelectorAll('img');

    images.forEach((img) => {
      // Add loading="lazy" for performance
      img.setAttribute('loading', 'lazy');

      // Add alt text if missing
      if (!img.alt) {
        img.alt = 'Image from book';
      }

      // Make images clickable for full-screen view
      img.style.cursor = 'pointer';
      img.addEventListener('click', () => {
        this.showImageFullscreen(img.src);
      });
    });
  }

  private showImageFullscreen(src: string): void {
    // Create modal for full-screen image viewing
    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);
  }

  private applyColumnLayout(): void {
    if (this.config.columnCount === 2) {
      this.container.style.columnCount = '2';
      this.container.style.columnGap = `${this.config.marginWidth}px`;
      this.container.style.columnRule = '1px solid var(--text-secondary)';
    } else {
      this.container.style.columnCount = 'auto';
    }
  }

  updateConfig(newConfig: Partial<RendererConfig>): void {
    this.config = { ...this.config, ...newConfig };

    // Re-render with new config
    const currentDoc = this.container.querySelector('.ebook-content')?.ownerDocument;
    if (currentDoc) {
      this.renderDocument(currentDoc as HTMLDocument);
    }
  }
}

5.3 CFI Navigation

File: web/src/reader/ebook/cfi-navigator.ts

// EPUB CFI (Canonical Fragment Identifier) navigation
// Reuses logic from internal/sync/format.go

// CFI format: /6/4[chap1ref]!/4[body01]/10/2/1:3[2]

interface CFIComponent {
  type: 'index' | 'indirection-step' | 'text-location';
  value: number;
  id?: string;
  textOffset?: number;
}

class CFINavigator {
  // Parse CFI string into components
  static parseCFI(cfi: string): CFIComponent[] {
    const components: CFIComponent[] = [];

    // Remove leading ! if present
    const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi;

    // Split by /
    const parts = cleanCFI.split('/').filter(Boolean);

    for (const part of parts) {
      // Parse component
      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]
        };

        if (match[3]) {
          component.textOffset = parseInt(match[3], 10);
        }

        components.push(component);
      }
    }

    return components;
  }

  // Generate CFI for a specific text node
  static 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;
  }

  // Navigate to CFI in document
  static navigateToCFI(doc: Document, cfi: string): Element | Text | null {
    const components = this.parseCFI(cfi);

    if (components.length === 0) return null;

    // First component is spine index (handled by caller)
    // Remaining components navigate through DOM

    let current: Node | null = doc.body;

    for (let i = 1; i < components.length; i++) {
      const component = components[i];

      if (component.type === 'index') {
        // Navigate to child index
        if (current instanceof Element) {
          const children = this.getElementChildren(current);
          current = children[component.value] || null;
        }
      }
    }

    return current as Element | Text;
  }

  // Get CFI for current selection
  static 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 = this.getElementChildren(parent);
        const index = siblings.indexOf(current as Element);
        path.unshift(index);
      }
      current = parent;
    }

    // Generate CFI
    const spineIndex = 0;  // This would come from current spine item
    const textOffset = range.startOffset;

    return this.generateCFI(spineIndex, path, textOffset);
  }

  // Get percentage from CFI
  static getPercentageFromCFI(cfi: string): number {
    // Simplified: use text location as percentage
    const components = this.parseCFI(cfi);
    const textLocation = components.find(c => c.type === 'text-location');

    if (textLocation && textLocation.textOffset !== undefined) {
      // Assume 1000 characters per "page" for rough calculation
      return Math.min((textLocation.textOffset / 10), 100);
    }

    return 0;
  }

  private static getElementChildren(element: Element): Element[] {
    return Array.from(element.children).filter(el =>
      el.nodeType === Node.ELEMENT_NODE
    ) as Element[];
  }

  // Compare two CFIs to determine reading order
  static compareCFIs(cfi1: string, cfi2: string): number {
    const components1 = this.parseCFI(cfi1);
    const components2 = this.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;
  }
}

5.4 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.4.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.4.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.4.3 Font Verification

Verify fonts are working:

# List all fonts
ls -lh web/static/fonts/*/

# Check file sizes (should be ~100-200KB each)
du -h web/static/fonts/*/*.*

# Verify WOFF2 format
file web/static/fonts/*/*.woff2

# Should output: "WOFF2 font data"

Add to git:

# Add fonts to repository
git add web/static/fonts/

# Commit
git commit -m "feat: add 8 bundled libre reading fonts

- Literata (default)
- Crimson Text
- Source Serif 4
- EB Garamond
- Libertinus Serif
- Noto Serif
- Charis SIL
- IBM Plex Serif

All fonts use SIL Open Font License 1.1
WOFF2 format, ~1.2MB total"

5.4.4 Font Loading in Templates

File: templates/reader.templ (updated)

Add to <head> section:

templ Reader(user User, metadata ReaderMetadata) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
            <title>{ metadata.title } - Bookhoard Reader</title>
            <link rel="manifest" href="/static/manifest.json"/>
            <link href="/static/reader-fonts.css" rel="stylesheet"/>
            <script src="/static/htmx.min.js"></script>
            <link href="/static/style.css" rel="stylesheet"/>
        </head>
        ...
}

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

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Literata:wght@0,400;0,700;1,400&family=Libertinus+Serif:wght@0,400;0,700;1,400&family=Noto+Serif:wght@0,400;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">

Why bundling is better:

  • Offline-ready (no network requests)
  • Privacy (Google doesn't track usage)
  • Faster (no DNS lookup, no TLS handshake)
  • Control (exact versions, no breaking changes)

5.4.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.4.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.5 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';
}

class TypographyEngine {
  private container: HTMLElement;
  private config: TypographyConfig;

  constructor(container: HTMLElement, config: TypographyConfig) {
    this.container = container;
    this.config = config;
  }

  apply(): void {
    const content = this.container.querySelector('.ebook-content');
    if (!content) return;

    // Get font stack for selected reading font
    const fontStack = getFontStack(this.config.readingFont);

    // Apply base styles
    content.setAttribute('style', `
      font-family: ${fontStack};
      font-size: ${this.config.fontSize}px;
      line-height: ${this.config.lineHeight};
      text-align: ${this.config.textAlign};
      margin-top: ${this.config.marginTop}px;
      margin-bottom: ${this.config.marginBottom}px;
      margin-left: ${this.config.marginLeft}px;
      margin-right: ${this.config.marginRight}px;
      text-indent: ${this.config.textIndent}px;
      -webkit-font-smoothing: ${this.config.fontSmoothing};
      -moz-osx-font-smoothing: ${this.config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'};
    `);

    // Apply hyphenation if enabled
    if (this.config.hyphenate) {
      this.enableHyphenation(content as HTMLElement);
    }

    // Enable/disable ligatures
    this.setLigatures(content as HTMLElement, this.config.ligatures);

    // Apply justification settings
    if (this.config.textAlign === 'justify') {
      this.enableJustification(content as HTMLElement);
    }
  }

  private enableHyphenation(element: HTMLElement): void {
    // Use CSS hyphens with lang attribute
    element.style.hyphens = 'auto';
    element.style.hyphenateLimitChars = '6 3 3';  // min 6, before 3, after 3

    // Add language attribute from EPUB metadata
    const lang = this.container.closest('[data-language]')?.getAttribute('data-language') || 'en';
    element.setAttribute('lang', lang);
  }

  private 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';
    }
  }

  private enableJustification(element: HTMLElement): void {
    // Add proper word breaking for justified text
    element.style.wordBreak = 'normal';
    element.style.overflowWrap = 'break-word';
    element.style.wordWrap = 'break-word';

    // Adjust letter spacing for better appearance
    element.style.letterSpacing = '0.01em';
  }

  updateConfig(newConfig: Partial<TypographyConfig>): void {
    this.config = { ...this.config, ...newConfig };
    this.apply();
  }

  // Measure reading time for current content
  measureReadingTime(wordsPerMinute: number = 250): number {
    const content = this.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);
  }

  // Get word count for current content
  getWordCount(): number {
    const content = this.container.querySelector('.ebook-content');
    if (!content) return 0;

    const text = content.textContent || '';
    return text.split(/\s+/).length;
  }
}

5.5 Search Within Ebook

File: web/src/reader/ebook/search.ts

// Search within ebook content

interface SearchResult {
  cfi: string;
  snippet: string;
  chapterTitle: string;
}

class EbookSearcher {
  private epubPackage: EPUBPackage;

  constructor(epubPackage: EPUBPackage) {
    this.epubPackage = epubPackage;
  }

  async search(query: string): Promise<SearchResult[]> {
    const results: SearchResult[] = [];
    const lowerQuery = query.toLowerCase();

    // Search all spine items
    for (const [index, spineItem] of this.epubPackage.spine.entries()) {
      const doc = await this.getSpineItemDocument(spineItem);

      if (!doc) continue;

      // Get chapter title
      const chapterTitle = this.getChapterTitle(spineItem);

      // Search in text nodes
      const textNodes = this.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) {
          // Generate CFI for this match
          const cfi = this.generateCFIForNode(node, foundAt);

          // Extract snippet with context
          const snippet = this.extractSnippet(text, foundAt, query.length);

          results.push({
            cfi,
            snippet,
            chapterTitle
          });

          foundAt += lowerQuery.length;
        }
      }
    }

    return results;
  }

  private async getSpineItemDocument(spineItem: EPUBSpineItem): Promise<Document | null> {
    try {
      const content = await this.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;
    }
  }

  private findTextNodes(root: Node): Text[] {
    const textNodes: Text[] = [];

    const walker = document.createTreeWalker(
      root,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode: (node) => {
          // Skip script, style, and empty text nodes
          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;
  }

  private generateCFIForNode(node: Text, offset: number): string {
    // Build path to node
    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;
    }

    // Get spine index (simplified - would need proper tracking)
    const spineIndex = 0;

    return CFINavigator.generateCFI(spineIndex, path, offset);
  }

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

    let snippet = text.substring(start, end);

    // Add ellipsis if truncated
    if (start > 0) snippet = '...' + snippet;
    if (end < text.length) snippet = snippet + '...';

    return snippet;
  }

  private getChapterTitle(spineItem: EPUBSpineItem): string {
    // Find TOC entry for this spine item
    for (const toc of this.epubPackage.toc) {
      if (toc.href === spineItem.href) {
        return toc.label;
      }

      // Check children
      for (const child of toc.children) {
        if (child.href === spineItem.href) {
          return child.label;
        }
      }
    }

    return 'Chapter ' + (this.epubPackage.spine.indexOf(spineItem) + 1);
  }
}

5.6 Copy Text Handler

File: web/src/reader/ebook/copy-handler.ts

// Handle text copying with citation

class CopyHandler {
  private currentMediaItem: MediaItemSummary;

  constructor(mediaItem: MediaItemSummary) {
    this.currentMediaItem = mediaItem;
  }

  async copySelection(): Promise<boolean> {
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0) return false;

    const selectedText = selection.toString();
    if (!selectedText.trim()) return false;

    // Create citation
    const citation = this.createCitation(selectedText);

    try {
      await navigator.clipboard.writeText(citation);

      // Show toast
      showToast('Copied to clipboard', 'success');

      return true;
    } catch (error) {
      console.error('Failed to copy:', error);
      showToast('Failed to copy to clipboard', 'error');
      return false;
    }
  }

  private createCitation(text: string): string {
    const citation = `"${text}"\n`;
    citation += `— ${this.currentMediaItem.title}`;
    if (this.currentMediaItem.author) {
      citation += ` by ${this.currentMediaItem.author}`;
    }
    citation += `\n(Source: Bookhoard)`;

    return citation;
  }

  // Enable copy on right-click
  enableContextMenuCopy(): void {
    document.addEventListener('contextmenu', async (e) => {
      const selection = window.getSelection();
      const selectedText = selection?.toString().trim();

      if (selectedText) {
        e.preventDefault();
        await this.copySelection();
      }
    });
  }
}

5.7 View Modes

File: web/src/reader/ebook/view-modes.ts

// Different viewing modes for ebooks

type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';

class ViewModeManager {
  private container: HTMLElement;
  private currentMode: ViewMode = 'paginated';

  constructor(container: HTMLElement) {
    this.container = container;
  }

  setViewMode(mode: ViewMode): void {
    this.currentMode = mode;
    this.applyMode();
  }

  private applyMode(): void {
    const content = this.container.querySelector('.ebook-content');
    if (!content) return;

    // Reset all modes
    content.classList.remove(
      'paginated',
      'scrolled',
      'single-column',
      'double-column'
    );

    // Apply current mode
    switch (this.currentMode) {
      case 'paginated':
        this.applyPaginatedMode(content as HTMLElement);
        break;
      case 'scrolled':
        this.applyScrolledMode(content as HTMLElement);
        break;
      case 'single-column':
        this.applySingleColumn(content as HTMLElement);
        break;
      case 'double-column':
        this.applyDoubleColumn(content as HTMLElement);
        break;
    }
  }

  private applyPaginatedMode(element: HTMLElement): void {
    element.classList.add('paginated');

    element.style.height = '100vh';
    element.style.overflow = 'hidden';
    element.style.columnCount = '1';
    element.style.columnGap = '0';

    // Enable page-by-page navigation
    this.enablePagination(element);
  }

  private applyScrolledMode(element: HTMLElement): void {
    element.classList.add('scrolled');

    element.style.height = 'auto';
    element.style.overflowY = 'auto';
    element.style.columnCount = '1';

    // Disable pagination
    this.disablePagination(element);
  }

  private 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';
  }

  private 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';
  }

  private enablePagination(element: HTMLElement): void {
    // Calculate pages based on content height
    const totalHeight = element.scrollHeight;
    const pageHeight = element.clientHeight;
    const pageCount = Math.ceil(totalHeight / pageHeight);

    // Add pagination controls
    this.addPaginationControls(pageCount);
  }

  private disablePagination(element: HTMLElement): void {
    // Remove pagination controls
    const controls = this.container.querySelector('.pagination-controls');
    controls?.remove();
  }

  private addPaginationControls(pageCount: number): void {
    let currentPage = 1;

    // Create controls UI
    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>
    `;

    // Add event listeners
    controls.querySelector('.prev-page')?.addEventListener('click', () => {
      if (currentPage > 1) {
        currentPage--;
        this.goToPage(currentPage);
      }
    });

    controls.querySelector('.next-page')?.addEventListener('click', () => {
      if (currentPage < pageCount) {
        currentPage++;
        this.goToPage(currentPage);
      }
    });

    this.container.appendChild(controls);
  }

  private goToPage(pageNumber: number): void {
    const content = this.container.querySelector('.ebook-content') as HTMLElement;
    if (!content) return;

    const pageHeight = content.clientHeight;
    const scrollTop = (pageNumber - 1) * pageHeight;

    content.scrollTo({
      top: scrollTop,
      behavior: 'smooth'
    });

    // Update page info
    const pageInfo = this.container.querySelector('.page-info');
    if (pageInfo) {
      pageInfo.textContent = `Page ${pageNumber} of ${this.getTotalPageCount()}`;
    }
  }

  private getTotalPageCount(): number {
    const content = this.container.querySelector('.ebook-content') as HTMLElement;
    if (!content) return 1;

    const totalHeight = content.scrollHeight;
    const pageHeight = content.clientHeight;

    return Math.ceil(totalHeight / pageHeight);
  }
}

6. Panel Detection Implementation

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

5.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
  }));
}

5.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);
}

6. Lazy Loading & Caching

6.1 Page Cache (5-Page Ahead)

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

// Lazy-loading page cache with 5-page ahead prefetch

class PageCache {
  private cache: Map<number, HTMLImageElement> = new Map();
  private loading: Set<number> = new Set();
  private maxAhead = 5;

  constructor(private mediaItemId: string) {}

  async getPage(pageNumber: number): Promise<HTMLImageElement> {
    // Check cache first
    if (this.cache.has(pageNumber)) {
      return this.cache.get(pageNumber)!;
    }

    // Check if already loading
    if (this.loading.has(pageNumber)) {
      // Wait for existing load
      return new Promise((resolve) => {
        const checkInterval = setInterval(() => {
          if (this.cache.has(pageNumber)) {
            clearInterval(checkInterval);
            resolve(this.cache.get(pageNumber)!);
          }
        }, 100);
      });
    }

    // Load page
    this.loading.add(pageNumber);
    const img = await this.loadPage(pageNumber);
    this.cache.set(pageNumber, img);
    this.loading.delete(pageNumber);

    // Prefetch next pages
    this.prefetch(pageNumber + 1);

    // Clean up old pages (keep last 10)
    this.cleanup(pageNumber);

    return img;
  }

  private async loadPage(pageNumber: number): Promise<HTMLImageElement> {
    const token = localStorage.getItem('token');
    const response = await fetch(
      `/api/readers/${this.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;
  }

  private prefetch(startPage: number): void {
    for (let i = startPage; i < startPage + this.maxAhead; i++) {
      if (!this.cache.has(i) && !this.loading.has(i)) {
        // Start loading in background
        this.loadPage(i).then((img) => {
          this.cache.set(i, img);
        });
      }
    }
  }

  private cleanup(currentPage: number): void {
    const keepPages = 10;
    for (const [page,] of this.cache) {
      if (page < currentPage - keepPages) {
        this.cache.delete(page);
      }
    }
  }
}

7. Offline Support (PWA)

7.1 Service Worker

File: web/static/service-worker.js (new file)

// Service worker for offline reading
const CACHE_NAME = 'bookhoard-reader-v1';
const OFFLINE_CACHE = 'bookhoard-offline';

// Cache dictionary data for offline use
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(OFFLINE_CACHE).then((cache) => {
      return cache.addAll([
        '/static/dictionary/en-US.json',
        '/static/dictionary/en-GB.json'
      ]);
    })
  );
});

// Cache reader pages
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Cache reader pages
  if (url.pathname.startsWith('/api/readers/') && url.pathname.includes('/pages/')) {
    event.respondWith(
      caches.open(CACHE_NAME).then((cache) => {
        return cache.match(event.request).then((response) => {
          if (response) {
            return response;
          }

          // Fetch and cache
          return fetch(event.request).then((networkResponse) => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });
        });
      })
    );
  }

  // Cache dictionary lookups
  if (url.pathname.startsWith('/api/readers/dictionary/')) {
    event.respondWith(
      caches.open(OFFLINE_CACHE).then((cache) => {
        return cache.match(event.request).then((response) => {
          if (response) {
            return response;
          }

          return fetch(event.request).then((networkResponse) => {
            // Cache dictionary responses
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });
        });
      })
    );
  }
});

// Cleanup old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

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

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

8. Dictionary Implementation

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

8.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 });
      }
    }
  });
}

9. Reading Statistics Integration

9.1 Track Reading Speed

File: web/src/reader/reading-speed-tracker.ts

// Track reading speed and update database

class ReadingSpeedTracker {
  private startTime: number | null = null;
  private pagesRead = 0;
  private wordsRead = 0;
  private lastSync = Date.now();

  constructor(private mediaItemId: string) {}

  startReadingSession(): void {
    this.startTime = Date.now();
    this.pagesRead = 0;
    this.wordsRead = 0;
  }

  recordPageTurn(): void {
    if (!this.startTime) return;

    this.pagesRead++;

    // Sync every 5 pages or every 5 minutes
    const now = Date.now();
    if (this.pagesRead % 5 === 0 || (now - this.lastSync) > 5 * 60 * 1000) {
      this.sync();
      this.lastSync = now;
    }
  }

  recordWordsRead(wordCount: number): void {
    this.wordsRead += wordCount;
  }

  private async sync(): void {
    if (!this.startTime) return;

    const minutesElapsed = (Date.now() - this.startTime) / (1000 * 60);
    const pagesPerMinute = this.pagesRead / minutesElapsed;
    const wordsPerMinute = this.wordsRead / minutesElapsed;

    await apiPut(`/readers/${this.mediaItemId}/reading-speed`, {
      pages_per_minute: pagesPerMinute,
      words_per_minute: wordsPerMinute,
      pages_read: this.pagesRead,
      total_reading_minutes: minutesElapsed
    });
  }
}

10. UI/UX Implementation

10.1 Reader Template (SSR)

File: templates/reader.templ (new file)

package templates

templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
            <title>{ metadata.title } - Bookhoard Reader</title>
            <link rel="manifest" href="/static/manifest.json"/>
            <script src="/static/htmx.min.js"></script>
            <link href="/static/style.css" rel="stylesheet"/>
        </head>
        <body
            x-data="readerShell"
            x-init="initReader()"
            class="theme-{ user.Theme }"
            data-media-item-id={ metadata.media_item_id }
            data-media-type={ metadata.library_type }
            data-total-pages={ metadata.total_pages }
        >
            @ReaderChrome(user, metadata, progress)

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

            @ReaderSettingsPanel()
            @ReaderTOCPanel(metadata)

            @DictionaryPopup()
        </body>
    </html>
}

templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
    <div id="reader-chrome" class="transition-opacity duration-300">
        <!-- Top bar -->
        <div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
            <div class="flex items-center justify-between px-4 py-3">
                <a href="/media-items/{ metadata.media_item_id }" class="text-lg hover:underline">
                     Back
                </a>
                <h1 class="text-lg font-semibold">{ metadata.title }</h1>
                <button
                    data-action="open-settings"
                    class="p-2 rounded-lg hover:bg-gray-700"
                    title="Settings"
                >
                    
                </button>
            </div>
        </div>

        <!-- Bottom bar -->
        <div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
            <div class="flex items-center justify-between px-4 py-3">
                <div id="progress-display" data-progress-mode="pages">
                    { fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
                </div>

                <div class="flex items-center gap-4">
                    <button data-action="toggle-toc" title="Table of Contents">📖</button>
                    <button data-action="add-bookmark" title="Bookmark">🏷</button>
                    <button data-action="add-note" title="Note">📝</button>
                </div>
            </div>
        </div>
    </div>
}

templ ReaderSettingsPanel() {
    <div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
        <div class="p-4">
            <h2 class="text-xl font-bold mb-4">Settings</h2>

            <!-- Display settings -->
            <div class="mb-6">
                <h3 class="font-semibold mb-2">Display</h3>
                <label class="block mb-2">
                    Chrome Behavior
                    <select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
                        <option value="auto-hide">Auto Hide</option>
                        <option value="always-visible">Always Visible</option>
                        <option value="hide-on-scroll">Hide on Scroll</option>
                    </select>
                </label>
                <label class="block mb-2">
                    Progress Mode
                    <select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
                        <option value="pages">Pages</option>
                        <option value="chapter">Chapter</option>
                        <option value="percentage">Percentage</option>
                        <option value="time-left">Time Left</option>
                    </select>
                </label>
            </div>

            <!-- Typography (ebooks only) -->
            <div class="mb-6" data-visible-for="ebook">
                <h3 class="font-semibold mb-2">Typography</h3>
                <label class="block mb-2">
                    Reading Font
                    <select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
                        <option value="literata">Literata (Default - Designed for ebooks)</option>
                        <option value="crimson">Crimson Text (Screen-optimized)</option>
                        <option value="source-serif">Source Serif 4 (Adobe quality)</option>
                        <option value="eb-garamond">EB Garamond (Classic)</option>
                        <option value="libertinus">Libertinus Serif (Technical)</option>
                        <option value="noto-serif">Noto Serif (All languages)</option>
                        <option value="charis-sil">Charis SIL (Multilingual)</option>
                        <option value="ibm-plex">IBM Plex Serif (Modern)</option>
                    </select>
                    <p class="text-xs mt-1" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
                </label>
                <label class="block mb-2">
                    Font Size
                    <input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
                    <span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
                </label>
                <label class="block mb-2">
                    Line Height
                    <input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
                    <span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
                </label>
            </div>

            <!-- Navigation -->
            <div class="mb-6">
                <h3 class="font-semibold mb-2">Navigation</h3>
                <label class="flex items-center mb-2">
                    <input type="checkbox" name="panel_zoom_enabled" class="mr-2"/>
                    Panel Zoom (Comics/Manga)
                </label>
                <label class="flex items-center mb-2">
                    <input type="checkbox" name="double_page_spread" class="mr-2"/>
                    Double Page Spread (Comics/Manga)
                </label>
            </div>

            <button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                Done
            </button>
        </div>
    </div>
}

templ ReaderTOCPanel(metadata ReaderMetadata) {
    <div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
        <div class="p-4">
            <h2 class="text-xl font-bold mb-4">Table of Contents</h2>

            <div id="toc-content">
                if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
                    for _, chapter := range metadata.chapter_metadata.Chapters {
                        <a
                            href="#"
                            data-chapter-id={ chapter.ID }
                            class="block py-2 hover:bg-gray-700 rounded px-2"
                        >
                            { chapter.Title }
                        </a>
                    }
                } else {
                    <p class="text-sm">No chapters available</p>
                }
            </div>

            <button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
                Close
            </button>
        </div>
    </div>
}

templ DictionaryPopup() {
    <div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
}

11. Integration Tests

11.1 Test Setup

File: cmd/server/tests/reader_test.go (new file)

Follow existing test patterns from media_test.go and auth_test.go:

package tests

import (
    "bookhoard/internal/database"
    "testing"
    "github.com/google/uuid"
    "github.com/jackc/pgx/v5/pgtype"
)

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

    // Create test user and media item
    ctx := setup.ctx
    queries := setup.queries

    user := createTestUser(t, ctx, queries)
    admin := createTestAdmin(t, ctx, queries)
    mediaItem := createTestMediaItem(t, ctx, queries, user.ID)

    accessToken := loginTestUser(t, setup, user.Email, "password123")
    adminToken := loginTestUser(t, setup, admin.Email, "admin123")

    t.Run("Get Reader Page - User", func(t *testing.T) {
        // Test SSR reader page
        // Test that user can access their own media items
    })

    t.Run("Get Reader Page - No User", func(t *testing.T) {
        // Test 401 without authentication
    })

    t.Run("Get Page - Lazy Loading", func(t *testing.T) {
        // Test page lazy loading endpoint
    })

    t.Run("Get Chapters", func(t *testing.T) {
        // Test chapter metadata endpoint
    })

    t.Run("Get Panels - Grid Detection", func(t *testing.T) {
        // Test panel detection endpoint
    })

    t.Run("Update Panels - Manual Override", func(t *testing.T) {
        // Test manual panel override (admin only)
    })

    t.Run("Reading Speed", func(t *testing.T) {
        // Test reading speed tracking
    })

    t.Run("Dictionary Lookup", func(t *testing.T) {
        // Test dictionary endpoint
    })

    t.Run("Settings Management", func(t *testing.T) {
        // Test settings CRUD
    })

    t.Run("Offline Support - Service Worker", func(t *testing.T) {
        // Test service worker registration
        // Test offline caching
    })
}

12. Phased Implementation

Phase 1: Infrastructure & Basic Reader (Week 1-2)

  • Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings)
  • Create reader service layer (internal/services/reader_service.go)
  • Create reader handlers (internal/handlers/reader.go)
  • Register reader routes (internal/router/reader.go)
  • Create reader template (templates/reader.templ)
  • Implement reader shell infrastructure
  • Implement settings manager (DB + localStorage)
  • Implement progress indicator (KOReader-style)
  • Create basic ebook reader (HTML rendering)
  • Create basic comic reader (image display)
  • Integration tests for infrastructure

Phase 2: Comic/Manga Features (Week 3-4)

  • Implement grid-based panel detection
  • Implement panel zoom with animations
  • Implement page cache (5-page ahead)
  • Implement manga RTL navigator
  • Implement manga vertical scroll mode
  • Implement chapter detection for all media types
  • Integration tests for comic/manga features

Phase 3: Advanced Features (Week 5-6)

  • Implement ML-based panel detection (optional enhancement)
  • Implement manual panel editor
  • Implement dictionary popup for ebooks
  • Implement offline dictionary cache
  • Implement reading speed tracker
  • Implement annotation manager (highlights, notes, bookmarks)
  • Integration tests for advanced features

Phase 4: Offline Support (Week 7)

  • Create service worker
  • Implement page caching for offline reading
  • Implement dictionary offline caching
  • Create PWA manifest
  • Implement online/offline detection
  • Integration tests for offline support

Phase 5: Polish & Testing (Week 8)

  • Performance optimization
  • Cross-browser testing
  • Mobile responsiveness testing
  • Accessibility testing
  • Security audit
  • Documentation (user guides, API docs)
  • End-to-end testing

13. Code Reuse Strategy

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

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

14. Bruno API Tests

File: bruno/reader/reader.bru (new folder)

Create Bruno OpenCollection YAML requests for:

bruno/reader/
├── get-reader-page.bru
├── get-page.bru
├── get-chapters.bru
├── get-panels.bru
├── update-panels.bru
├── get-reading-speed.bru
├── update-reading-speed.bru
├── lookup-word.bru
├── get-settings.bru
└── update-settings.bru

Follow existing Bruno patterns from bruno/media/ and bruno/auth/.


15. Documentation

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

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

16. Success Criteria

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

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

Quality Requirements

  • Zero TypeScript errors
  • All integration tests passing
  • Zero known security vulnerabilities
  • Mobile-responsive (320px - 4K)
  • Keyboard accessible
  • WCAG 2.1 AA compliant

17. 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 hybrid architecture balances code reuse with medium-specific optimization, while the phased approach allows for incremental development and testing.

Key principles:

  • Reuse existing systems (WebSocket sync, progress tracking, annotations)
  • Surgical code edits (extend, don't duplicate)
  • Follow existing patterns (service layer, test patterns)
  • Progressive enhancement (SSR-first, JavaScript enhancements)
  • Privacy-first (per-user settings, 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)

Key design decisions:

  • Fonts: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only (400, 400i, 700, 700i)
  • Theming: Hybrid approach - all 11 Bookhoard themes for UI/chrome, 5 reading-optimized themes for ebook text
  • Typography: Optimized for extended reading (Literata default, designed for Google Play Books)
  • UI Elements: Share fonts with rest of Bookhoard app (not reading fonts)

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
  4. Implement service layer and handlers
  5. Build reader template and frontend infrastructure

Plan created: 2025 Last updated: 2025