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

3768 lines
113 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📖 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
```sql
-- Panel detection data
CREATE TABLE panel_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
panels JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, page_number)
);
CREATE INDEX idx_panel_data_media_item ON panel_data(media_item_id);
-- Reading speed tracking
CREATE TABLE reading_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
words_per_minute DECIMAL(6,2),
pages_per_minute DECIMAL(6,2),
pages_read INTEGER DEFAULT 0,
total_reading_minutes DECIMAL(8,2) DEFAULT 0,
last_read_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX idx_reading_speed_item ON reading_speed(media_item_id);
-- Dictionary cache (for offline use)
CREATE TABLE dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
word VARCHAR(100) NOT NULL UNIQUE,
definition TEXT NOT NULL,
part_of_speech VARCHAR(20),
example TEXT,
etymology TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
accessed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_dictionary_word ON dictionary_cache(word);
-- Reader settings (per-user preferences)
CREATE TABLE reader_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(50) NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, setting_key)
);
CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);
```
### 2.2 Alter Existing Tables
```sql
-- Add chapter metadata to media_items
ALTER TABLE media_items
ADD COLUMN chapter_metadata JSONB;
-- Example structure:
-- {
-- "chapters": [
-- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20},
-- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25}
-- ]
-- }
-- Note: reading_progress table already exists with epubcfi, page, percentage fields
-- Note: notes and highlights tables already exist
-- Note: bookmarks table already exists
```
### 2.3 Schema.sql Implementation
**File:** `database/schema/schema.sql`
Add the above tables to the schema file. Follow existing patterns:
- Use `gen_random_uuid()` for UUID defaults
- Use `TIMESTAMPTZ DEFAULT NOW()` for timestamps
- Add appropriate indexes for foreign keys
- Use `ON DELETE CASCADE` for referential integrity
---
## 3. API Endpoints
### 3.1 Reader Routes
**File:** `internal/router/reader.go` (new file)
```go
package router
func registerReaderRoutes(cfg *Config) {
e := cfg.Echo
jwtMiddleware := createJWTMiddleware(cfg)
reader := e.Group("/readers", jwtMiddleware)
// Reader page (SSR)
reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader)
// Content serving (lazy-loaded pages)
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
// Chapter metadata
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
// Panel data
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:**
```go
type ReaderHandler struct {
db *database.Queries
libraryService *services.LibraryService
worker *services.Worker
}
func (h *ReaderHandler) ShowReader(c echo.Context) error
func (h *ReaderHandler) GetPage(c echo.Context) error
func (h *ReaderHandler) GetChapters(c echo.Context) error
func (h *ReaderHandler) GetPanels(c echo.Context) error
func (h *ReaderHandler) UpdatePanels(c echo.Context) error
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error
func (h *ReaderHandler) LookupWord(c echo.Context) error
func (h *ReaderHandler) GetSettings(c echo.Context) error
func (h *ReaderHandler) UpdateSettings(c echo.Context) error
```
### 3.3 Service Layer
**File:** `internal/services/reader_service.go` (new file)
All business logic goes here, not in handlers:
```go
type ReaderService struct {
db *database.Queries
worker *services.Worker
}
// Chapter detection for all media types
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error)
// Panel detection (grid-based, ML, manual)
func (s *ReaderService) DetectPanels(ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string) ([]Panel, error)
// Reading speed calculation
func (s *ReaderService) CalculateReadingSpeed(ctx context.Context, userID, mediaItemID uuid.UUID, pagesRead int, minutes float64) error
// Dictionary lookup (with cache)
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error)
// Settings management (DB + localStorage sync)
func (s *ReaderService) GetSettings(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error)
func (s *ReaderService) UpdateSettings(ctx context.Context, userID uuid.UUID, settings map[string]interface{}) error
```
---
## 4. Frontend Implementation
### 4.1 File Structure
```
web/src/reader/
├── reader.ts - Main reader entry point
├── reader-shell.ts - UI shell, chrome control
├── progress-indicator.ts - KOReader-style switchable progress
├── settings-manager.ts - Settings (DB + localStorage)
├── slide-in-panel.ts - Shared slide-in panel (TOC + Settings)
├── annotation-manager.ts - Highlights, notes, bookmarks
├── websocket-sync.ts - Reuse existing websocket.ts
├── dictionary-popup.ts - Offline dictionary lookup
├── ebook/
│ ├── epub-parser.ts - EPUB parsing (ZIP + XML)
│ ├── html-renderer.ts - Browser-native rendering
│ ├── cfi-navigator.ts - EPUB CFI navigation
│ ├── typography-engine.ts - Font rendering, themes
│ └── chapter-detector.ts - Chapter detection
├── comic/
│ ├── image-parser.ts - CBZ/CBR/PDF parsing
│ ├── canvas-renderer.ts - Canvas rendering
│ ├── panel-detector.ts - Grid + ML + manual
│ ├── panel-navigator.ts - Panel zoom animations
│ └── page-cache.ts - 5-page ahead cache
└── manga/
├── rtl-navigator.ts - Right-to-left navigation
└── vertical-scroll.ts - Webtoon-style scroll
```
### 4.2 TypeScript Types
**File:** `web/src/types/reader.d.ts` (new file)
```typescript
// 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`
```typescript
// Shared reader infrastructure
// Implements chrome control, routing, settings sync
import { Alpine } from "../alpine";
import { getReaderMetadata, updateReadingProgress } from "./api";
import { SettingsManager } from "./settings-manager";
import { ProgressIndicator } from "./progress-indicator";
let currentReader: EbookReader | 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`
```typescript
// KOReader-style switchable progress indicator
import { Alpine } from "../alpine";
import { getReadingSpeed } from "./api";
interface ProgressDisplay {
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
text: string;
}
function calculateProgress(
currentPage: number,
totalPages: number,
currentChapterPage: number,
chapterPages: number,
readingSpeed?: ReadingSpeed
): ProgressDisplay {
const mode = getCurrentProgressMode(); // From settings
switch (mode) {
case 'pages':
return {
mode: 'pages',
text: `${currentPage}/${totalPages}`
};
case 'chapter':
return {
mode: 'chapter',
text: `${currentChapterPage}/${chapterPages}`
};
case 'percentage':
const percentage = Math.round((currentPage / totalPages) * 100);
return {
mode: 'percentage',
text: `${percentage}%`
};
case 'time-left':
if (!readingSpeed) {
return { mode: 'time-left', text: '--:--' };
}
const pagesLeft = totalPages - currentPage;
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return {
mode: 'time-left',
text: `${hours}h ${mins}m`
};
}
}
function cycleProgressMode(): void {
const modes: Array<'pages' | 'chapter' | 'percentage' | 'time-left'> =
['pages', 'chapter', 'percentage', 'time-left'];
const currentMode = getCurrentProgressMode();
const currentIndex = modes.indexOf(currentMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setProgressMode(nextMode);
}
```
### 4.5 Settings Manager (DB + localStorage)
**File:** `web/src/reader/settings-manager.ts`
```typescript
// Per-user settings with localStorage fallback
import { apiGet, apiPut } from "../api";
import { getToken, setItem, getItem } from "../storage";
const SETTINGS_KEY = 'reader_settings';
const LOCALSTORAGE_KEY = 'reader_settings_local';
interface SettingsManager {
load(): Promise<ReaderSettings>;
save(settings: Partial<ReaderSettings>): Promise<void>;
sync(): Promise<void>; // Sync localStorage → DB
get(key: keyof ReaderSettings): any;
set(key: keyof ReaderSettings, value: any): Promise<void>;
}
async function loadSettings(): Promise<ReaderSettings> {
const token = getToken();
if (!token) {
// Fallback to localStorage
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
try {
const response = await apiGet('/readers/settings');
const settings = await response.json();
// Cache in localStorage
setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
return settings;
} catch (error) {
// Fallback to localStorage on error
const local = getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
}
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
const token = getToken();
if (!token) {
// Save to localStorage only
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut('/readers/settings', settings);
// Update localStorage cache
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
// Fallback to localStorage
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: 'auto-hide',
progress_mode: 'pages',
chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available
reading_theme: 'dark', // Ebook text: 5 reading-optimized themes
reading_font: 'literata', // Default reading font (designed for ebooks)
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
font_size: 16,
line_height: 1.6,
margin_width: 20,
double_page_spread: false,
reading_direction: 'ltr',
hardware_acceleration: true
};
}
```
---
## 5. Ebook Reader Implementation
### 5.1 EPUB Parser
**File:** `web/src/reader/ebook/epub-parser.ts`
```typescript
// 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`
```typescript
// HTML rendering with theme support, font loading, and image handling
interface RendererConfig {
readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; // Reading-optimized themes
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts
fontSize: number;
lineHeight: number;
marginWidth: number;
textAlign: 'left' | 'justify';
columnCount: 1 | 2; // Single or double column
}
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`
```typescript
// 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)
```bash
#!/bin/bash
# Fetch and prepare libre reading fonts for Bookhoard
# Usage: ./scripts/fetch-reading-fonts.sh
set -e
FONTS_DIR="web/static/fonts"
mkdir -p "$FONTS_DIR"
echo "📦 Downloading libre reading fonts for Bookhoard..."
# 1. Literata (v2.001 - latest stable)
echo "Downloading Literata..."
wget -O /tmp/literata.zip "https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip"
unzip -q /tmp/literata.zip -d /tmp/literata
mkdir -p "$FONTS_DIR/literata"
# Convert to WOFF2 using fonttools
for file in /tmp/literata/Static/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
fi
done
rm -rf /tmp/literata /tmp/literata.zip
# 2. Crimson Text (v1.102)
echo "Downloading Crimson Text..."
wget -O /tmp/crimson.zip "https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip"
unzip -q /tmp/crimson.zip -d /tmp/crimson
mkdir -p "$FONTS_DIR/crimson"
for file in /tmp/crimson/OTF/CrimsonPro-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Roman"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400.woff2" --flavor=woff2
elif [[ $basename == *"Roman-Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/crimson /tmp/crimson.zip
# 3. Source Serif 4 (v4.004)
echo "Downloading Source Serif 4..."
wget -O /tmp/source-serif.zip "https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip"
unzip -q /tmp/source-serif.zip -d /tmp/source-serif
mkdir -p "$FONTS_DIR/source-serif"
for file in /tmp/source-serif/OTF/SourceSerif4-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400.woff2" --flavor=woff2
elif [[ $basename == *"It"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldIt"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/source-serif /tmp/source-serif.zip
# 4. EB Garamond (v0.016)
echo "Downloading EB Garamond..."
wget -O /tmp/ebgaramond.zip "https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip"
unzip -q /tmp/ebgaramond.zip -d /tmp/ebgaramond
mkdir -p "$FONTS_DIR/eb-garamond"
for file in /tmp/ebgaramond/otf/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/ebgaramond /tmp/ebgaramond.zip
# 5. Libertinus Serif (v7.050)
echo "Downloading Libertinus Serif..."
wget -O /tmp/libertinus.zip "https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip"
unzip -q /tmp/libertinus.zip -d /tmp/libertinus
mkdir -p "$FONTS_DIR/libertinus"
for file in /tmp/libertinus/LibertinusSerif-*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/libertinus /tmp/libertinus.zip
# 6. Noto Serif (v2.013 - subset to common languages only to reduce size)
echo "Downloading Noto Serif..."
wget -O /tmp/noto-serif.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Regular.ttf"
pyftsubset /tmp/noto-serif.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400.woff2" --flavor=woff2 --unicodes='U+0000-007F' --text-file="common-latin.txt"
wget -O /tmp/noto-serif-i.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Italic.ttf"
pyftsubset /tmp/noto-serif-i.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
wget -O /tmp/noto-serif-b.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf"
pyftsubset /tmp/noto-serif-b.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700.woff2" --flavor=woff2 --unicodes='U+0000-007F'
wget -O /tmp/noto-serif-bi.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf"
pyftsubset /tmp/noto-serif-bi.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
rm -f /tmp/noto-serif*.zip
# 7. Charis SIL (v6.200)
echo "Downloading Charis SIL..."
wget -O /tmp/charis.zip "https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip"
unzip -q /tmp/charis.zip -d /tmp/charis
mkdir -p "$FONTS_DIR/charis-sil"
for file in /tmp/charis/CharisSIL-6.200/*.ttf; do
basename=$(basename "$file" .ttf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400.woff2" --flavor=woff2
elif [[ $basename == *"Italic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400i.woff2" --flavor=woff2
elif [[ $basename == *"Bold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700.woff2" --flavor=woff2
elif [[ $basename == *"BoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/charis /tmp/charis.zip
# 8. IBM Plex Serif (v1.1.0)
echo "Downloading IBM Plex Serif..."
wget -O /tmp/ibm-plex.zip "https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip"
unzip -q /tmp/ibm-plex.zip -d /tmp/ibm-plex
mkdir -p "$FONTS_DIR/ibm-plex"
for file in /tmp/ibm-plex/OpenType/IBM-Plex-Serif/*.otf; do
basename=$(basename "$file" .otf)
if [[ $basename == *"Regular"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400.woff2" --flavor=woff2
elif [[ $basename == *"TextItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400i.woff2" --flavor=woff2
elif [[ $basename == *"SemiBold"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700.woff2" --flavor=woff2
elif [[ $basename == *"SemiBoldItalic"* ]]; then
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700i.woff2" --flavor=woff2
fi
done
rm -rf /tmp/ibm-plex /tmp/ibm-plex.zip
echo "✅ All fonts downloaded and converted to WOFF2"
echo "📊 Total size:"
du -sh "$FONTS_DIR"
echo "🔍 Verifying fonts..."
ls -lh "$FONTS_DIR"/*/
echo "✨ Font setup complete!"
```
**Manual Setup (Alternative)**
If you prefer manual setup or the script fails:
| Font | Version | Download URL | License |
|------|---------|-------------|---------|
| **Literata** | v2.001 | https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip | SIL OFL 1.1 |
| **Crimson Text** | v1.102 | https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip | SIL OFL 1.1 |
| **Source Serif 4** | v4.004 | https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip | SIL OFL 1.1 |
| **EB Garamond** | v0.016 | https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip | SIL OFL 1.1 |
| **Libertinus Serif** | v7.050 | https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip | SIL OFL 1.1 |
| **Noto Serif** | v2.013 | https://github.com/googlefonts/noto-fonts (subset to Latin-1) | SIL OFL 1.1 |
| **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 |
| **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 |
#### 5.4.2 Font Conversion Requirements
**Required Tools:**
```bash
# Python fonttools for WOFF2 conversion
pip install fonttools brotli
# Alternative: Google Fonts woff2 tool
git clone --recursive https://github.com/google/woff2.git
cd woff2
make
sudo cp woff2_compress /usr/local/bin/
sudo cp woff2_decompress /usr/local/bin/
```
**Conversion Commands:**
```bash
# Using fonttools (recommended)
pyftsubset input.otf --output-file=output.woff2 \
--flavor=woff2 \
--layout-features='*' \
--unicodes='U+0000-10FFFF'
# Using woff2_compress tool
woff2_compress input.otf output.woff2
```
#### 5.4.3 Font Verification
**Verify fonts are working:**
```bash
# List all fonts
ls -lh web/static/fonts/*/
# Check file sizes (should be ~100-200KB each)
du -h web/static/fonts/*/*.*
# Verify WOFF2 format
file web/static/fonts/*/*.woff2
# Should output: "WOFF2 font data"
```
**Add to git:**
```bash
# Add fonts to repository
git add web/static/fonts/
# Commit
git commit -m "feat: add 8 bundled libre reading fonts
- Literata (default)
- Crimson Text
- Source Serif 4
- EB Garamond
- Libertinus Serif
- Noto Serif
- Charis SIL
- IBM Plex Serif
All fonts use SIL Open Font License 1.1
WOFF2 format, ~1.2MB total"
```
#### 5.4.4 Font Loading in Templates
**File:** `templates/reader.templ` (updated)
Add to `<head>` section:
```go
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
...
}
```
#### 5.4.5 Alternative: Use Google Fonts CDN (Not Recommended)
If you don't want to bundle fonts (slower initial load, privacy concerns):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Literata:wght@0,400;0,700;1,400&family=Libertinus+Serif:wght@0,400;0,700;1,400&family=Noto+Serif:wght@0,400;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
```
**Why bundling is better:**
- ✅ Offline-ready (no network requests)
- ✅ Privacy (Google doesn't track usage)
- ✅ Faster (no DNS lookup, no TLS handshake)
- ✅ Control (exact versions, no breaking changes)
#### 5.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):**
```bash
pyftsubset NotoSerif-Regular.ttf \
--output-file=NotoSerif-400.woff2 \
--flavor=woff2 \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215'
```
This reduces Noto Serif from ~180KB to ~50KB per style.
#### 5.4.7 Font Loading Performance
**Critical Rendering Path Optimization:**
```typescript
// Preload default font (Literata) in HTML head
<link rel="preload" href="/static/fonts/literata/Literata-400.woff2" as="font" type="font/woff2" crossorigin>
// Preload user's preferred font (from settings)
<link rel="preload" href="/static/fonts/crimson/CrimsonText-400.woff2" as="font" type="font/woff2" crossorigin>
```
**Lazy-load other fonts:**
```typescript
// Load other fonts on demand
async function loadFont(fontId: string): Promise<void> {
const font = READING_FONTS.find(f => f.id === fontId);
if (!font) return;
document.fonts.load(`16px "${font.stack}"`);
}
```
```
web/static/fonts/
├── literata/
│ ├── Literata-400.woff2 (200KB)
│ ├── Literata-400i.woff2 (200KB)
│ ├── Literata-700.woff2 (180KB)
│ └── Literata-700i.woff2 (180KB)
├── crimson/
│ ├── CrimsonText-400.woff2 (100KB)
│ ├── CrimsonText-400i.woff2 (100KB)
│ ├── CrimsonText-700.woff2 (95KB)
│ └── CrimsonText-700i.woff2 (95KB)
├── source-serif/
│ ├── SourceSerif4-400.woff2 (150KB)
│ ├── SourceSerif4-400i.woff2 (150KB)
│ ├── SourceSerif4-700.woff2 (145KB)
│ └── SourceSerif4-700i.woff2 (145KB)
├── eb-garamond/
│ ├── EBGaramond-400.woff2 (120KB)
│ ├── EBGaramond-400i.woff2 (120KB)
│ ├── EBGaramond-700.woff2 (115KB)
│ └── EBGaramond-700i.woff2 (115KB)
├── libertinus/
│ ├── LibertinusSerif-400.woff2 (150KB)
│ ├── LibertinusSerif-400i.woff2 (150KB)
│ ├── LibertinusSerif-700.woff2 (145KB)
│ └── LibertinusSerif-700i.woff2 (145KB)
├── noto-serif/
│ ├── NotoSerif-400.woff2 (180KB)
│ ├── NotoSerif-400i.woff2 (180KB)
│ ├── NotoSerif-700.woff2 (175KB)
│ └── NotoSerif-700i.woff2 (175KB)
├── charis-sil/
│ ├── CharisSIL-400.woff2 (130KB)
│ ├── CharisSIL-400i.woff2 (130KB)
│ ├── CharisSIL-700.woff2 (125KB)
│ └── CharisSIL-700i.woff2 (125KB)
└── ibm-plex/
├── IBMPlexSerif-400.woff2 (140KB)
├── IBMPlexSerif-400i.woff2 (140KB)
├── IBMPlexSerif-700.woff2 (135KB)
└── IBMPlexSerif-700i.woff2 (135KB)
```
**File:** `web/static/reader-fonts.css` (new file)
```css
/* Libre reading fonts for Bookhoard ebook reader */
/* Literata - Designed for Google Play Books */
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Literata';
src: url('/static/fonts/literata/Literata-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Crimson Text - Optimized for screen reading */
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Crimson Text';
src: url('/static/fonts/crimson/CrimsonText-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Source Serif 4 - Adobe professional quality */
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: url('/static/fonts/source-serif/SourceSerif4-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* EB Garamond - Classic elegance */
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'EB Garamond';
src: url('/static/fonts/eb-garamond/EBGaramond-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Libertinus Serif - Academic/technical */
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Libertinus Serif';
src: url('/static/fonts/libertinus/LibertinusSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Noto Serif - Maximum language support */
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Noto Serif';
src: url('/static/fonts/noto-serif/NotoSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* Charis SIL - Multilingual specialist */
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Charis SIL';
src: url('/static/fonts/charis-sil/CharisSIL-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
/* IBM Plex Serif - Modern & versatile */
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400i.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700i.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
```
**Font Loading Strategy:**
**File:** `web/src/reader/ebook/font-loader.ts` (new file)
```typescript
// Font loading with performance optimization
const READING_FONTS = [
{
id: 'literata',
name: 'Literata',
stack: 'Literata, serif',
description: 'Designed for Google Play Books'
},
{
id: 'crimson',
name: 'Crimson Text',
stack: 'Crimson Text, serif',
description: 'Optimized for screen reading'
},
{
id: 'source-serif',
name: 'Source Serif 4',
stack: 'Source Serif 4, serif',
description: 'Professional Adobe quality'
},
{
id: 'eb-garamond',
name: 'EB Garamond',
stack: 'EB Garamond, serif',
description: 'Classic elegance'
},
{
id: 'libertinus',
name: 'Libertinus Serif',
stack: 'Libertinus Serif, serif',
description: 'Excellent for technical content'
},
{
id: 'noto-serif',
name: 'Noto Serif',
stack: 'Noto Serif, serif',
description: 'Maximum language support'
},
{
id: 'charis-sil',
name: 'Charis SIL',
stack: 'Charis SIL, serif',
description: 'Multilingual specialist'
},
{
id: 'ibm-plex',
name: 'IBM Plex Serif',
stack: 'IBM Plex Serif, serif',
description: 'Modern & versatile'
}
];
// Preload critical fonts (default font + user's last choice)
async function preloadFonts(userPreferredFont: string): Promise<void> {
const fontsToPreload = new Set(['literata', userPreferredFont]);
for (const fontId of fontsToPreload) {
const font = READING_FONTS.find(f => f.id === fontId);
if (font) {
document.fonts.load(`16px "${font.stack}"`);
}
}
}
// Get font stack for CSS
function getFontStack(fontId: string): string {
const font = READING_FONTS.find(f => f.id === fontId);
return font?.stack || 'Literata, serif';
}
// All fonts bundled - no network requests needed
export { READING_FONTS, preloadFonts, getFontStack };
```
**Important Notes:**
- **UI Elements**: Use Bookhoard's existing font stack (not these reading fonts)
- **Ebook Content Only**: These fonts apply only to `.ebook-content` elements
- **Bundled**: All fonts ship with the app (~1.2MB total, WOFF2 compressed)
- **Offline Ready**: No network requests needed for font loading
- **Performance**: Preload default font (Literata) + user's preference
- **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK)
### 5.5 Typography Engine
**File:** `web/src/reader/ebook/typography-engine.ts`
```typescript
// Typography engine with font smoothing, hyphenation, and justification
interface TypographyConfig {
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts
fontSize: number;
lineHeight: number;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
textAlign: 'left' | 'right' | 'center' | 'justify';
textIndent: number;
hyphenate: boolean;
ligatures: boolean;
fontSmoothing: 'auto' | 'antialiased' | 'subpixel-antialiased';
}
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`
```typescript
// 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`
```typescript
// 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`
```typescript
// 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`
```typescript
// Grid-based panel detection (fast, lightweight)
interface GridConfig {
rows: number;
cols: number;
}
function detectPanelsGrid(
imageData: ImageData,
config: GridConfig = { rows: 3, cols: 3 }
): Panel[] {
const panels: Panel[] = [];
const cellWidth = imageData.width / config.cols;
const cellHeight = imageData.height / config.rows;
for (let y = 0; y < config.rows; y++) {
for (let x = 0; x < config.cols; x++) {
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
if (!isEmpty(cell)) {
panels.push({
id: `panel-${panels.length}`,
x: (x / config.cols) * 100,
y: (y / config.rows) * 100,
width: (1 / config.cols) * 100,
height: (1 / config.rows) * 100,
reading_order: panels.length
});
}
}
}
return mergeAdjacentPanels(panels);
}
function isEmpty(cellData: ImageData): boolean {
// Simple edge detection to find empty space
// Count white/transparent pixels
let emptyPixels = 0;
const totalPixels = cellData.width * cellData.height;
const threshold = 0.95; // 95% empty = empty cell
for (let i = 0; i < cellData.data.length; i += 4) {
const r = cellData.data[i];
const g = cellData.data[i + 1];
const b = cellData.data[i + 2];
const a = cellData.data[i + 3];
// Consider white or transparent as empty
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
emptyPixels++;
}
}
return (emptyPixels / totalPixels) > threshold;
}
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
// Merge panels that are next to each other
// Simplified algorithm - can be enhanced
const merged: Panel[] = [];
const used = new Set<number>();
for (let i = 0; i < panels.length; i++) {
if (used.has(i)) continue;
let current = { ...panels[i] };
used.add(i);
// Look for adjacent panels
for (let j = i + 1; j < panels.length; j++) {
if (used.has(j)) continue;
if (isAdjacent(current, panels[j])) {
current = mergePanels(current, panels[j]);
used.add(j);
}
}
merged.push(current);
}
return merged;
}
```
### 5.2 ML-Based Detection (Enhancement)
**File:** `web/src/reader/comic/panel-ml-detector.ts`
```typescript
// ML-based panel detection (optional, lazy-loaded)
// Uses TensorFlow.js for accurate panel detection
let modelLoaded = false;
let panelModel: any = null;
async function loadMLModel(): Promise<void> {
if (modelLoaded) return;
try {
// Lazy-load TensorFlow.js
await import('@tensorflow/tfjs');
// Load pre-trained model for panel detection
// Model should be small (~2MB) and fast
panelModel = await loadModel('/static/models/panel-detection/model.json');
modelLoaded = true;
} catch (error) {
console.error('Failed to load ML model:', error);
// Fall back to grid-based detection
}
}
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
if (!modelLoaded) {
await loadMLModel();
}
if (!panelModel) {
// Fall back to grid-based
return detectPanelsGrid(imageData);
}
// Run ML model
const predictions = await panelModel.detect(imageData);
// Convert predictions to Panel format
return predictions.map((pred: any, index: number) => ({
id: `ml-panel-${index}`,
x: pred.bbox.x * 100,
y: pred.bbox.y * 100,
width: pred.bbox.width * 100,
height: pred.bbox.height * 100,
reading_order: index
}));
}
```
### 5.3 Manual Override
**File:** `web/src/reader/comic/panel-editor.ts`
```typescript
// Manual panel editor for admins/power users
function openPanelEditor(pageNumber: number): void {
const modal = document.getElementById('panel-editor-modal');
modal?.classList.remove('hidden');
// Load page image
const canvas = document.getElementById('panel-editor-canvas') as HTMLCanvasElement;
const ctx = canvas?.getContext('2d');
// Load image and draw to canvas
loadImageForPage(pageNumber).then((image) => {
canvas!.width = image.width;
canvas!.height = image.height;
ctx?.drawImage(image, 0, 0);
// Allow user to draw panels
enablePanelDrawing(canvas!);
});
}
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
let isDrawing = false;
let startX = 0;
let startY = 0;
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
startX = e.offsetX;
startY = e.offsetY;
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
// Draw selection rectangle
const ctx = canvas.getContext('2d');
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
});
canvas.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
// Save panel
const panel: Panel = {
id: `manual-${Date.now()}`,
x: (startX / canvas.width) * 100,
y: (startY / canvas.height) * 100,
width: ((e.offsetX - startX) / canvas.width) * 100,
height: ((e.offsetY - startY) / canvas.height) * 100,
reading_order: 0 // Will be set by server
};
saveManualPanel(panel);
});
}
async function saveManualPanel(panel: Panel): Promise<void> {
const mediaItemId = document.body.dataset.mediaItemId;
const pageNumber = getCurrentPageNumber();
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
detection_method: 'manual',
panels: [panel]
});
// Reload with new panels
loadPage(pageNumber);
}
```
---
## 6. Lazy Loading & Caching
### 6.1 Page Cache (5-Page Ahead)
**File:** `web/src/reader/comic/page-cache.ts`
```typescript
// 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)
```javascript
// Service worker for offline reading
const CACHE_NAME = 'bookhoard-reader-v1';
const OFFLINE_CACHE = 'bookhoard-offline';
// Cache dictionary data for offline use
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.addAll([
'/static/dictionary/en-US.json',
'/static/dictionary/en-GB.json'
]);
})
);
});
// Cache reader pages
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache reader pages
if (url.pathname.startsWith('/api/readers/') && url.pathname.includes('/pages/')) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((response) => {
if (response) {
return response;
}
// Fetch and cache
return fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
// Cache dictionary lookups
if (url.pathname.startsWith('/api/readers/dictionary/')) {
event.respondWith(
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache dictionary responses
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
});
// Cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) {
return caches.delete(cacheName);
}
})
);
})
);
});
```
### 7.2 PWA Manifest
**File:** `web/static/manifest.json` (new file)
```json
{
"name": "Bookhoard Reader",
"short_name": "Reader",
"description": "Offline-capable ebook and comic reader",
"start_url": "/dashboard",
"display": "fullscreen",
"background_color": "#1a1b26",
"theme_color": "#1a1b26",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"offline_enabled": true
}
```
### 7.3 Register Service Worker
**File:** `web/src/reader/offline-manager.ts` (new file)
```typescript
// Offline manager for PWA functionality
export function registerServiceWorker(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/service-worker.js')
.then((registration) => {
console.log('Service worker registered:', registration);
})
.catch((error) => {
console.error('Service worker registration failed:', error);
});
}
}
export function checkOnlineStatus(): boolean {
if (typeof navigator !== 'undefined' && navigator.onLine) {
return true;
}
return false;
}
// Listen for online/offline events
window.addEventListener('online', () => {
showToast('Back online', 'success');
// Sync any pending changes
syncPendingChanges();
});
window.addEventListener('offline', () => {
showToast('You are offline. Some features may be limited.', 'warning');
});
```
---
## 8. Dictionary Implementation
### 8.1 Dictionary Data
**File:** `web/static/dictionary/en-US.json` (new file)
Compressed dictionary data with common words. Format:
```json
{
"word": {
"definition": "A single distinct meaningful element of speech or writing",
"part_of_speech": "noun",
"example": "The words 'the', 'and', and 'word' are examples of words.",
"etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort."
}
}
```
Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage.
### 8.2 Dictionary Popup
**File:** `web/src/reader/ebook/dictionary-popup.ts`
```typescript
// Dictionary lookup popup for ebooks
import { lookupWord } from "./api";
function showDictionaryPopup(word: string, position: { x: number; y: number }): void {
// Remove existing popup
const existing = document.getElementById('dictionary-popup');
existing?.remove();
// Create popup
const popup = document.createElement('div');
popup.id = 'dictionary-popup';
popup.className = 'absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50';
popup.style.left = `${position.x}px`;
popup.style.top = `${position.y}px`;
popup.innerHTML = '<p class="text-sm">Loading...</p>';
document.body.appendChild(popup);
// Look up word
lookupWord(word).then((entry) => {
popup.innerHTML = `
<h3 class="font-bold text-lg">${entry.word}</h3>
<p class="text-sm italic">${entry.part_of_speech || ''}</p>
<p class="mt-2">${entry.definition}</p>
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ''}
`;
}).catch((error) => {
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
});
// Close on click outside
setTimeout(() => {
document.addEventListener('click', function closePopup(e: MouseEvent) {
if (!popup.contains(e.target as Node)) {
popup.remove();
document.removeEventListener('click', closePopup);
}
});
}, 100);
}
// Text selection handler for ebooks
function handleTextSelection(): void {
document.addEventListener('mouseup', () => {
const selection = window.getSelection();
const selectedText = selection?.toString().trim();
if (selectedText && selectedText.split(' ').length === 1) {
// Single word selected - show dictionary
const range = selection?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect) {
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
}
}
});
}
```
---
## 9. Reading Statistics Integration
### 9.1 Track Reading Speed
**File:** `web/src/reader/reading-speed-tracker.ts`
```typescript
// 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)
```go
package templates
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
x-data="readerShell"
x-init="initReader()"
class="theme-{ user.Theme }"
data-media-item-id={ metadata.media_item_id }
data-media-type={ metadata.library_type }
data-total-pages={ metadata.total_pages }
>
@ReaderChrome(user, metadata, progress)
<main id="reader-content" class="w-full h-full">
<!-- Content loaded by JavaScript based on media type -->
</main>
@ReaderSettingsPanel()
@ReaderTOCPanel(metadata)
@DictionaryPopup()
</body>
</html>
}
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300">
<!-- Top bar -->
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<a href="/media-items/{ metadata.media_item_id }" class="text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.title }</h1>
<button
data-action="open-settings"
class="p-2 rounded-lg hover:bg-gray-700"
title="Settings"
>
</button>
</div>
</div>
<!-- Bottom bar -->
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<div id="progress-display" data-progress-mode="pages">
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
</div>
<div class="flex items-center gap-4">
<button data-action="toggle-toc" title="Table of Contents">📖</button>
<button data-action="add-bookmark" title="Bookmark">🏷</button>
<button data-action="add-note" title="Note">📝</button>
</div>
</div>
</div>
</div>
}
templ ReaderSettingsPanel() {
<div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<!-- Display settings -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Display</h3>
<label class="block mb-2">
Chrome Behavior
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
<option value="auto-hide">Auto Hide</option>
<option value="always-visible">Always Visible</option>
<option value="hide-on-scroll">Hide on Scroll</option>
</select>
</label>
<label class="block mb-2">
Progress Mode
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
<option value="pages">Pages</option>
<option value="chapter">Chapter</option>
<option value="percentage">Percentage</option>
<option value="time-left">Time Left</option>
</select>
</label>
</div>
<!-- Typography (ebooks only) -->
<div class="mb-6" data-visible-for="ebook">
<h3 class="font-semibold mb-2">Typography</h3>
<label class="block mb-2">
Reading Font
<select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
<option value="literata">Literata (Default - Designed for ebooks)</option>
<option value="crimson">Crimson Text (Screen-optimized)</option>
<option value="source-serif">Source Serif 4 (Adobe quality)</option>
<option value="eb-garamond">EB Garamond (Classic)</option>
<option value="libertinus">Libertinus Serif (Technical)</option>
<option value="noto-serif">Noto Serif (All languages)</option>
<option value="charis-sil">Charis SIL (Multilingual)</option>
<option value="ibm-plex">IBM Plex Serif (Modern)</option>
</select>
<p class="text-xs mt-1" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
</label>
<label class="block mb-2">
Font Size
<input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
</label>
<label class="block mb-2">
Line Height
<input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
</label>
</div>
<!-- Navigation -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Navigation</h3>
<label class="flex items-center mb-2">
<input type="checkbox" name="panel_zoom_enabled" class="mr-2"/>
Panel Zoom (Comics/Manga)
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="double_page_spread" class="mr-2"/>
Double Page Spread (Comics/Manga)
</label>
</div>
<button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
}
templ ReaderTOCPanel(metadata ReaderMetadata) {
<div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
<div id="toc-content">
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
for _, chapter := range metadata.chapter_metadata.Chapters {
<a
href="#"
data-chapter-id={ chapter.ID }
class="block py-2 hover:bg-gray-700 rounded px-2"
>
{ chapter.Title }
</a>
}
} else {
<p class="text-sm">No chapters available</p>
}
</div>
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
Close
</button>
</div>
</div>
}
templ DictionaryPopup() {
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
}
```
---
## 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`:
```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:**
```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*