diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..bad0976 --- /dev/null +++ b/READER_IMPLEMENTATION_PLAN.md @@ -0,0 +1,3767 @@ +# 📖 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; + save(settings: Partial): Promise; + sync(): Promise; // Sync localStorage → DB + get(key: keyof ReaderSettings): any; + set(key: keyof ReaderSettings, value: any): Promise; +} + +async function loadSettings(): Promise { + 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): Promise { + 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; // 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 { + // 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 { + 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 { + 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 { + 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 { + 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> { + const resources = new Map(); + + 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 { + 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 { + 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 = new Set(); + + constructor(container: HTMLElement, config: RendererConfig) { + this.container = container; + this.config = config; + } + + async renderDocument(doc: HTMLDocument): Promise { + // 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> = { + '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 { + // 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 { + 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): 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 `` section: + +```go +templ Reader(user User, metadata ReaderMetadata) { + + + + + + { metadata.title } - Bookhoard Reader + + + + + + ... +} +``` + +#### 5.4.5 Alternative: Use Google Fonts CDN (Not Recommended) + +If you don't want to bundle fonts (slower initial load, privacy concerns): + +```html + + + +``` + +**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 + + +// Preload user's preferred font (from settings) + +``` + +**Lazy-load other fonts:** + +```typescript +// Load other fonts on demand +async function loadFont(fontId: string): Promise { + 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 { + 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): 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 { + 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 { + 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 { + 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 = ` + + Page ${currentPage} of ${pageCount} + + `; + + // 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(); + + 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 { + 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 { + 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 { + 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 = new Map(); + private loading: Set = new Set(); + private maxAhead = 5; + + constructor(private mediaItemId: string) {} + + async getPage(pageNumber: number): Promise { + // 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 { + 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 = '

Loading...

'; + document.body.appendChild(popup); + + // Look up word + lookupWord(word).then((entry) => { + popup.innerHTML = ` +

${entry.word}

+

${entry.part_of_speech || ''}

+

${entry.definition}

+ ${entry.example ? `

"${entry.example}"

` : ''} + `; + }).catch((error) => { + popup.innerHTML = `

Definition not found for "${word}"

`; + }); + + // 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) { + + + + + + { metadata.title } - Bookhoard Reader + + + + + + @ReaderChrome(user, metadata, progress) + +
+ +
+ + @ReaderSettingsPanel() + @ReaderTOCPanel(metadata) + + @DictionaryPopup() + + +} + +templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) { +
+ +
+
+ + ← Back + +

{ metadata.title }

+ +
+
+ + +
+
+
+ { fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) } +
+ +
+ + + +
+
+
+
+} + +templ ReaderSettingsPanel() { +
+
+

Settings

+ + +
+

Display

+ + +
+ + +
+

Typography

+ + + +
+ + +
+

Navigation

+ + +
+ + +
+
+} + +templ ReaderTOCPanel(metadata ReaderMetadata) { +
+
+

Table of Contents

+ +
+ if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 { + for _, chapter := range metadata.chapter_metadata.Chapters { + + { chapter.Title } + + } + } else { +

No chapters available

+ } +
+ + +
+
+} + +templ DictionaryPopup() { + +} +``` + +--- + +## 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*