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