# 📖 Bookhoard Reader Implementation Plan ## Overview Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types. **Design Philosophy:** - **Universal reader architecture**: One rendering engine with pluggable parser components - **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure - **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT) - **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md) - **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems - **Progressive enhancement**: SSR-first with TypeScript enhancements - **Privacy-first**: Per-user settings with localStorage fallback - **Offline-capable**: PWA with offline dictionary - **Full PDF support**: Mozilla pdf.js for text selection, highlights, search - **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, mini-map, copy support ## What's New in This Version ### **Major Architecture Change: Universal Reader + Parsers** **Previous approach:** Separate readers for each format (EbookReader, ComicReader, etc.) **New approach:** Single universal reader with parser pipeline ``` All Reflowable Ebooks → Parse to CIF → Universal Reader ├── EPUB → EPUBParser → CIF → Universal Reader ├── FB2 → FB2Parser → CIF → Universal Reader ├── TXT → TXTParser → CIF → Universal Reader ├── HTML → HTMLParser → CIF → Universal Reader ├── MOBI → Server Parser → CIF → Universal Reader ├── AZW3 → Server Parser → CIF → Universal Reader ├── DOCX → Server Parser → CIF → Universal Reader └── RTF → Server Parser → CIF → Universal Reader ``` **Benefits:** - One codebase for UI/UX (fix once, works for all formats) - Easy to add new formats (just implement parser interface) - Consistent user experience across all ebooks - ~500 KB total dependency size (vs. 182 MB Calibre) ### **Procedural TypeScript (No OOP)** All code follows PROJECT_GUIDELINES.md: - ❌ No classes - ❌ No `this` capture - ❌ No inheritance - ✅ Functions and modules - ✅ Functional techniques where helpful - ✅ Procedural/imperative style **Example:** ```typescript // ❌ OLD (OOP - not allowed) class EPUBParser { private zip: JSZip | null = null; async parse(blob: Blob): Promise { ... } } // ✅ NEW (Procedural - correct) export async function parseEPUB(blob: Blob): Promise { ... } ``` --- ## 1. Architecture ### 1.1 Universal Reader with Pluggable Parsers **Architectural Decision: Single Reader + Parser Pipeline** Instead of separate readers for each format, we use **one universal reader** with **pluggable parsers** that convert all formats to a **Common Intermediate Format (CIF)**. ``` ┌──────────────────────────────────────────────────────────┐ │ Universal Ebook Reader (Single) │ │ - HTML Renderer (shared) │ │ - Typography Engine (shared) │ │ - Progress Tracker (shared) │ │ - Annotation Manager (shared) │ │ - Navigation Controls (shared) │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ Common Intermediate Format (CIF) │ │ - Standardized HTML structure │ │ - Universal metadata schema │ │ - Unified navigation (TOC) │ │ - Consistent resource loading │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ Parser Manager (Router) │ │ Detects format → Routes to appropriate parser │ └──────────────────────────────────────────────────────────┘ ↓ ┌─────────┬─────────┬──────────┬──────────┐ │ EPUB │ FB2 │ TXT │ HTML │ ← Client-side │ Parser │ Parser │ Parser │ Parser │ (TypeScript) └─────────┴─────────┴──────────┴──────────┘ ┌─────────┬─────────┬──────────┬──────────┐ │ MOBI │ AZW3 │ DOCX │ RTF │ ← Server-side │ Parser │ Parser │ Parser │ Parser │ (Go backend) └─────────┴─────────┴──────────┴──────────┘ PDF and Comics use dedicated readers (not CIF pipeline): - PDFReader (pdf.js) - Fixed-layout documents - ComicReader (canvas) - Image archives - MangaReader (extends Comic) - RTL/vertical modes ``` **Why This Approach?** 1. **Code Reuse**: One reader implementation for all reflowable ebooks 2. **Consistency**: All formats have identical UI/UX 3. **Maintainability**: Fix bug once, applies to all formats 4. **Extensibility**: Add new format by implementing parser interface 5. **Performance**: Client-side for simple formats, server-side for complex ### 1.2 Component Structure ``` Reader Infrastructure (Shared) ├── reader-shell.ts - UI shell, chrome control, routing ├── progress-tracker.ts - Integration with reading_progress table ├── annotation-manager.ts - Integration with notes/highlights tables ├── websocket-sync.ts - Reuse existing sync system ├── settings-manager.ts - Per-user preferences (DB + localStorage) ├── bookmark-manager.ts - Integration with existing bookmarks └── chapter-detector.ts - Chapter detection for all media types Universal Ebook Reader (Reflowable Formats) ├── html-renderer.ts - Browser-native HTML rendering (shared) ├── typography-engine.ts - Font rendering, theme integration (shared) ├── cfi-navigator.ts - Universal position navigation ├── dictionary-popup.ts - Offline dictionary lookup Parser Pipeline ├── parser-manager.ts - Routes format → appropriate parser ├── cif-types.ts - Common Intermediate Format types │ ├── Client-Side Parsers (TypeScript) │ ├── epub-parser.ts - EPUB 2/3 parsing (ZIP + XML) │ ├── fb2-parser.ts - FictionBook 2 parsing (XML) │ ├── txt-parser.ts - Plain text wrapper │ └── html-parser.ts - Standalone HTML files │ └── Server-Side Parsers (Go backend) ├── mobi-parser.go - MOBI parsing ├── azw3-parser.go - AZW3/KF8 parsing ├── docx-parser.go - Word document parsing └── rtf-parser.go - Rich Text Format parsing PDF Reader (Fixed Layout) ├── pdfjs-wrapper.ts - Mozilla pdf.js integration ├── text-layer-renderer.ts - Text layer overlay for selection ├── annotation-layer.ts - Highlight/note rendering ├── pdf-navigation.ts - Page navigation, zoom, fit modes ├── pdf-search.ts - Full-text search within PDF ├── page-cache.ts - 5-page ahead cache ├── text-selection.ts - Text selection and highlight creation ├── pdf-outline.ts - TOC navigation ├── pdf-bookmarks.ts - Custom bookmarks ├── pdf-clipboard.ts - Copy to clipboard ├── pdf-links.ts - Internal link handling ├── pdf-dual-page.ts - Dual page spread view ├── pdf-minimap.ts - Mini-map navigation ├── pdf-rotation.ts - Rotated page support └── pdf-page-sizes.ts - Variable page size handling Comic Reader (Image Archives) ├── image-archive-parser.ts - CBZ/CBR parsing ├── canvas-renderer.ts - Canvas rendering with lazy loading ├── panel-detector.ts - Grid-based + ML + manual override ├── panel-navigator.ts - Panel zoom with smooth animations └── page-cache.ts - 5-page ahead cache Manga Reader (extends Comic) ├── rtl-navigator.ts - Right-to-left navigation ├── vertical-scroll-mode.ts - Webtoon-style vertical scroll └── panel-detector.ts - Manga-aware panel detection ``` ### 1.3 Theming Strategy (Hybrid Approach) **Design Decision:** Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices: ``` ┌─────────────────────────────────────────────────────────┐ │ UI Chrome (Bars, Panels, Settings) │ │ ✅ All 11 Bookhoard themes available │ │ - tokyo-night, dracula, nord, etc. │ │ - Maintains consistency with rest of app │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ │ Ebook Text Content │ │ ✅ 5 reading-optimized themes only │ │ - Light (standard) │ │ - Sepia (warm, easier on eyes) │ │ - Dark (reduced eye strain) │ │ - Night (reduced blue light for better sleep) │ │ - High Contrast (accessibility) │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ │ PDF Documents │ │ ✅ 5 reading-optimized themes only │ │ - Light, Sepia, Dark, Night, High Contrast │ │ - PDF.js supports custom CSS for text layer │ │ - Maintains readability for long documents │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ │ Comic/Manga Images │ │ ✅ All 11 Bookhoard themes available │ │ - Visual content works well with any theme │ │ - No eye fatigue concerns with images │ └─────────────────────────────────────────────────────────┘ ``` **Why This Approach?** 1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization 2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes 3. **Accessibility**: Reading-optimized themes help users with visual impairments 4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions 5. **Flexibility**: Still have full theming for UI and visual content **Popular Ebook Reader Comparison:** | Reader | Reading Themes | Color Options? | |-------------|----------------|----------------| | Kindle | 4 | No | | Kobo | 4 | No (green for night) | | Apple Books | 5 | No | | **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** | **Implementation:** - `chrome_theme`: Applied to reader shell, navigation bars, settings panels - `reading_theme`: Applied to ebook text content only (5 options) - Comics/manga: Use `chrome_theme` (all 11 themes work well) ### 1.4 Data Flow ``` User opens reader ↓ Backend: GET /api/readers/:mediaItemId ↓ Verify access, fetch metadata, progress, bookmarks ↓ SSR render: templates/reader.templ with initial data ↓ Frontend: Initialize appropriate reader (Ebook/PDF/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); -- PDF bookmarks (custom user bookmarks) CREATE TABLE pdf_bookmarks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, page_number INTEGER NOT NULL, title VARCHAR(255) NOT NULL, position VARCHAR(100), -- 'pdf:page:45' for consistency created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(media_item_id, user_id, page_number) ); CREATE INDEX idx_pdf_bookmarks_media ON pdf_bookmarks(media_item_id); CREATE INDEX idx_pdf_bookmarks_user ON pdf_bookmarks(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 (comics/manga) reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels) reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) // Manual override // PDF outline/TOC reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline) // PDF thumbnails (for mini-map) reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail) // Reading speed reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed) reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed) // Dictionary lookup reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord) // Reader settings reader.GET("/settings", cfg.ReaderHandler.GetSettings) reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) } // Bookmarks API (reuse existing media routes) func registerBookmarkRoutes(cfg *Config) { e := cfg.Echo jwtMiddleware := createJWTMiddleware(cfg) bookmarks := e.Group("/api/media-items/:mediaItemId/bookmarks", jwtMiddleware) // CRUD operations for bookmarks bookmarks.GET("", cfg.ReaderHandler.GetBookmarks) bookmarks.POST("", cfg.ReaderHandler.CreateBookmark) bookmarks.DELETE("/:bookmarkId", cfg.ReaderHandler.DeleteBookmark) } ``` ### 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 // ============================================================ // Common Intermediate Format (CIF) Types // Universal format for all reflowable ebooks after parsing // ============================================================ interface EbookCIF { // Universal metadata (all formats) metadata: { title: string; author: string; language: string; publisher?: string; isbn?: string; coverImage?: Blob; }; // Unified navigation structure toc: TOCNode[]; // Content spine (reading order) spine: SpineItem[]; // Resources (CSS, fonts, images) resources: Map; // Progress tracking (minimal - backend handles detailed tracking) locations: { totalCharacters: number; estimatedPages: number; }; } interface SpineItem { id: string; type: 'html' | 'image'; content: string; properties?: string[]; // Minimal position info for UI index: number; } interface TOCNode { id: string; title: string; href: string; children: TOCNode[]; } // ============================================================ // Parser Types (Procedural, not OOP) // ============================================================ type ParserFormat = 'epub' | 'fb2' | 'txt' | 'html' | 'mobi' | 'azw3' | 'docx' | 'rtf'; interface ParserCapabilities { canParse(mimeType: string, extension: string): boolean; parse(file: Blob): Promise; extractMetadata(file: Blob): Promise>; } // ============================================================ // Reader Metadata (from API) // ============================================================ interface ReaderMetadata { media_item_id: string; title: string; author: string; cover_image_path: string; library_type: 'ebook' | 'comic' | 'manga' | 'pdf'; mime_type: string; file_path: string; chapter_metadata?: ChapterMetadata; total_pages?: number; } // ============================================================ // Other Shared Types // ============================================================ interface ChapterMetadata { chapters: Chapter[]; } interface Chapter { id: string; title: string; start_page: number; page_count: number; } interface PanelData { media_item_id: string; page_number: number; detection_method: 'grid' | 'ml' | 'manual'; panels: Panel[]; updated_at: string; } interface Panel { id: string; x: number; y: number; width: number; height: number; reading_order: number; } interface ReadingSpeed { words_per_minute: number; pages_per_minute: number; pages_read: number; total_reading_minutes: number; last_read_at: string; } interface DictionaryEntry { word: string; definition: string; part_of_speech?: string; example?: string; etymology?: string; } interface ReaderSettings { chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll'; progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; chrome_theme: string; reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; font_size: number; line_height: number; margin_width: number; tap_zone_size: number; auto_scroll: boolean; panel_zoom_enabled: boolean; double_page_spread: boolean; reading_direction: 'ltr' | 'rtl' | 'vertical'; pdf_fit_mode: 'fit-width' | 'fit-page' | 'fit-height' | 'none'; pdf_zoom_level: number; pdf_text_layer_enabled: boolean; pdf_dual_page_mode: 'auto' | 'single' | 'dual'; pdf_dual_page_threshold: number; pdf_minimap_enabled: boolean; pdf_outline_visible: boolean; pdf_bookmarks_visible: boolean; hardware_acceleration: boolean; } interface ProgressDisplay { mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; current: number; total: number; label?: string; time_left?: string; } ``` ### 4.3 Universal Reader Shell (Procedural) **File:** `web/src/reader/reader-shell.ts` ```typescript // Universal Reader Shell - Routes to appropriate reader // Procedural style: Functions, not classes import { Alpine } from "../alpine"; import { getReaderMetadata, updateReadingProgress } from "./api"; import { SettingsManager } from "./settings-manager"; import { ProgressIndicator } from "./progress-indicator"; import { parseEbook, requiresServerParsing } from './parser-manager'; import { initializePDFReader } from './pdf/pdfjs-wrapper'; import { initializeComicReader } from './comic/image-parser'; // ============================================================ // Reader State // ============================================================ let currentReader: UniversalReader | PDFReader | ComicReader | MangaReader | null = null; let readerMetadata: ReaderMetadata | null = null; interface UniversalReader { type: 'ebook'; cif: EbookCIF; currentSpineIndex: number; } interface PDFReader { type: 'pdf'; doc: any; currentPage: number; } interface ComicReader { type: 'comic'; images: Blob[]; currentPage: number; } interface MangaReader { type: 'manga'; images: Blob[]; currentPage: number; readingDirection: 'rtl' | 'vertical'; } // ============================================================ // Initialization // ============================================================ async function initializeReader(): Promise { const mediaItemId = document.body.dataset.mediaItemId; if (!mediaItemId) return; // Fetch metadata readerMetadata = await getReaderMetadata(mediaItemId); // Initialize appropriate reader based on type switch (readerMetadata.library_type) { case 'ebook': currentReader = await initializeEbookReader(readerMetadata); break; case 'pdf': currentReader = await initializePDFReader(readerMetadata); break; case 'comic': currentReader = await initializeComicReader(readerMetadata); break; case 'manga': currentReader = await initializeMangaReader(readerMetadata); break; } if (currentReader) { setupReaderUI(); } } async function initializeEbookReader(metadata: ReaderMetadata): Promise { // Check if server-side parsing is needed const needsServer = requiresServerParsing(metadata.mime_type, getFileExtension(metadata.file_path)); let ebookFile: Blob; if (needsServer) { // Fetch parsed CIF from server const response = await fetch(`/api/readers/${metadata.media_item_id}/parse`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mime_type: metadata.mime_type, file_path: metadata.file_path, }), }); if (!response.ok) { throw new Error(`Server parsing failed: ${response.statusText}`); } ebookFile = await response.blob(); } else { // Fetch original file for client-side parsing const response = await fetch(metadata.file_path); ebookFile = await response.blob(); } // Parse ebook to CIF const cif = await parseEbook(ebookFile, metadata.mime_type, getFileExtension(metadata.file_path)); return { type: 'ebook', cif, currentSpineIndex: 0, }; } // ============================================================ // UI Setup // ============================================================ function setupReaderUI(): void { if (!currentReader || !readerMetadata) return; // Setup chrome setupChromeBehavior(); // Setup progress indicator setupProgressIndicator(); // Setup annotations setupAnnotations(); // Setup keyboard navigation setupKeyboardNavigation(); } function setupChromeBehavior(): void { const chrome = document.getElementById('reader-chrome'); if (!chrome) return; // Auto-hide on scroll let hideTimeout: NodeJS.Timeout; window.addEventListener('scroll', () => { chrome.classList.add('visible'); clearTimeout(hideTimeout); hideTimeout = setTimeout(() => { chrome.classList.remove('visible'); }, 2000); }); // Toggle on tap (for touch devices) chrome.addEventListener('click', () => { chrome.classList.toggle('visible'); }); } function setupProgressIndicator(): void { // Update progress based on reader type if (!currentReader) return; if (currentReader.type === 'ebook') { updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex); } else if (currentReader.type === 'pdf') { updatePDFProgress(currentReader.currentPage, readerMetadata.total_pages || 0); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { updateComicProgress(currentReader.currentPage, currentReader.images.length); } } function setupAnnotations(): void { // Load existing highlights and notes // Implementation depends on annotation system } function setupKeyboardNavigation(): void { document.addEventListener('keydown', (e) => { if (!currentReader) return; switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); nextPage(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); previousPage(); break; } }); } // ============================================================ // Navigation Functions // ============================================================ function nextPage(): void { if (!currentReader) return; if (currentReader.type === 'ebook') { nextSpineItem(); } else if (currentReader.type === 'pdf') { nextPDFPage(); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { nextComicPage(); } } function previousPage(): void { if (!currentReader) return; if (currentReader.type === 'ebook') { previousSpineItem(); } else if (currentReader.type === 'pdf') { previousPDFPage(); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { previousComicPage(); } } function nextSpineItem(): void { if (currentReader?.type !== 'ebook') return; if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) { currentReader.currentSpineIndex++; renderCurrentSpineItem(); } } function previousSpineItem(): void { if (currentReader?.type !== 'ebook') return; if (currentReader.currentSpineIndex > 0) { currentReader.currentSpineIndex--; renderCurrentSpineItem(); } } function renderCurrentSpineItem(): void { if (currentReader?.type !== 'ebook') return; const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex]; const container = document.getElementById('reader-content'); if (!container) return; // Render spine item content container.innerHTML = spineItem.content; // Apply theme and typography applyReaderTheme(); applyTypography(); // Update progress updateProgress(); } // ============================================================ // Progress Tracking // ============================================================ function updateProgress(): void { if (!currentReader || !readerMetadata) return; let percentage = 0; let currentPosition = ''; if (currentReader.type === 'ebook') { const totalSpine = currentReader.cif.spine.length; percentage = (currentReader.currentSpineIndex + 1) / totalSpine; currentPosition = `spine:${currentReader.currentSpineIndex}`; } else if (currentReader.type === 'pdf') { const totalPages = readerMetadata.total_pages || 1; percentage = currentReader.currentPage / totalPages; currentPosition = `page:${currentReader.currentPage}`; } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { const totalPages = currentReader.images.length; percentage = currentReader.currentPage / totalPages; currentPosition = `page:${currentReader.currentPage}`; } // Send to backend updateReadingProgress(readerMetadata.media_item_id, { percentage, current_page: currentReader.type === 'ebook' ? currentReader.currentSpineIndex : currentReader.currentPage, position: currentPosition, }); } // ============================================================ // Alpine.js Integration // ============================================================ Alpine.data('readerShell', () => ({ init() { initializeReader(); }, nextPage, previousPage, get currentPage() { if (!currentReader) return 0; if (currentReader.type === 'ebook') { return currentReader.currentSpineIndex + 1; } else { return currentReader.currentPage; } }, get totalPages() { if (!currentReader || !readerMetadata) return 0; if (currentReader.type === 'ebook') { return currentReader.cif.spine.length; } else if (currentReader.type === 'pdf') { return readerMetadata.total_pages || 0; } else { return currentReader.images.length; } }, })); // ============================================================ // Utility Functions // ============================================================ function getFileExtension(filepath: string): string { const match = filepath.match(/\.([^.]+)$/); return match ? `.${match[1]}` : ''; } function applyReaderTheme(): void { // Apply reading theme from settings const settings = getReaderSettings(); const container = document.getElementById('reader-content'); if (!container) return; container.className = `ebook-content theme-${settings.reading_theme}`; } function applyTypography(): void { const settings = getReaderSettings(); const container = document.getElementById('reader-content'); if (!container) return; container.style.fontSize = `${settings.font_size}px`; container.style.lineHeight = settings.line_height.toString(); container.style.fontFamily = getFontStack(settings.reading_font); } function getFontStack(font: string): string { const stacks: Record = { 'literata': '"Literata", serif', 'crimson': '"Crimson Text", serif', 'source-serif': '"Source Serif 4", serif', 'eb-garamond': '"EB Garamond", serif', 'libertinus': '"Libertinus Serif", serif', 'noto-serif': '"Noto Serif", serif', 'charis-sil': '"Charis SIL", serif', 'ibm-plex': '"IBM Plex Serif", serif', }; return stacks[font] || stacks['literata']; } function getReaderSettings(): ReaderSettings { // Load from settings manager return {} as ReaderSettings; // Simplified } ``` --- ### 4.4 Server-Side Parsers (Go Backend) **File:** `internal/handlers/reader.go` (new file) ```go package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "github.com/labstack/echo/v5" "github.com/google/uuid" ) type ReaderHandler struct { db *database.Queries libraryService *services.LibraryService } func NewReaderHandler(db *database.Queries, libraryService *services.LibraryService) *ReaderHandler { return &ReaderHandler{ db: db, libraryService: libraryService, } } // ParseEbook parses complex ebook formats on the server func (h *ReaderHandler) ParseEbook(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(400, map[string]string{"error": "Invalid media item ID"}) } // Fetch media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), parsedUUID) if err != nil { return c.JSON(404, map[string]string{"error": "Media item not found"}) } // Route to appropriate parser based on format var cif interface{} switch mediaItem.MimeType.String { case "application/x-mobipocket-ebook": cif, err = h.parseMOBI(c.Request().Context(), mediaItem.FilePath) case "application/vnd.amazon.mobi8-ebook": cif, err = h.parseAZW3(c.Request().Context(), mediaItem.FilePath) case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": cif, err = h.parseDOCX(c.Request().Context(), mediaItem.FilePath) case "application/rtf": cif, err = h.parseRTF(c.Request().Context(), mediaItem.FilePath) default: return c.JSON(400, map[string]string{"error": "Unsupported format for server-side parsing"}) } if err != nil { return c.JSON(500, map[string]string{"error": "Parsing failed: " + err.Error()}) } return c.JSON(200, cif) } // Placeholder parser implementations func (h *ReaderHandler) parseMOBI(ctx context.Context, filePath string) (interface{}, error) { // TODO: Implement MOBI parsing return nil, echo.NewHTTPError(501, "MOBI parser not implemented yet") } func (h *ReaderHandler) parseAZW3(ctx context.Context, filePath string) (interface{}, error) { // TODO: Implement AZW3 parsing return nil, echo.NewHTTPError(501, "AZW3 parser not implemented yet") } func (h *ReaderHandler) parseDOCX(ctx context.Context, filePath string) (interface{}, error) { // TODO: Implement DOCX parsing return nil, echo.NewHTTPError(501, "DOCX parser not implemented yet") } func (h *ReaderHandler) parseRTF(ctx context.Context, filePath string) (interface{}, error) { // TODO: Implement RTF parsing return nil, echo.NewHTTPError(501, "RTF parser not implemented yet") } ``` **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 | PDFReader | 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 'pdf': currentReader = new PDFReader(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.5 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.6 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 Parser Manager (Procedural) **File:** `web/src/reader/parser-manager.ts` ```typescript // Parser Manager - Routes files to appropriate parsers // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Parser Registry // ============================================================ const PARSER_REGISTRY: ParserEntry[] = [ { format: 'epub', mimeType: 'application/epub+zip', extensions: ['.epub'], side: 'client' }, { format: 'fb2', mimeType: 'application/fb2', extensions: ['.fb2', '.fb2.zip'], side: 'client' }, { format: 'txt', mimeType: 'text/plain', extensions: ['.txt'], side: 'client' }, { format: 'html', mimeType: 'text/html', extensions: ['.html', '.htm'], side: 'client' }, { format: 'mobi', mimeType: 'application/x-mobipocket-ebook', extensions: ['.mobi', '.azw'], side: 'server' }, { format: 'azw3', mimeType: 'application/vnd.amazon.mobi8-ebook', extensions: ['.azw3'], side: 'server' }, { format: 'docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'], side: 'server' }, { format: 'rtf', mimeType: 'application/rtf', extensions: ['.rtf'], side: 'server' }, ]; interface ParserEntry { format: string; mimeType: string; extensions: string[]; side: 'client' | 'server'; } // ============================================================ // Parser Detection // ============================================================ export function detectParserFormat(mimeType: string, extension: string): ParserEntry | null { return PARSER_REGISTRY.find(entry => entry.mimeType === mimeType || entry.extensions.includes(extension.toLowerCase()) ) || null; } export function requiresServerParsing(mimeType: string, extension: string): boolean { const entry = detectParserFormat(mimeType, extension); return entry?.side === 'server' || false; } // ============================================================ // Main Parse Function (Router) // ============================================================ export async function parseEbook(file: Blob, mimeType: string, extension: string): Promise { const entry = detectParserFormat(mimeType, extension); if (!entry) { throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`); } if (entry.side === 'server') { return parseEbookOnServer(file, entry.format); } else { return parseEbookOnClient(file, entry.format); } } // ============================================================ // Client-Side Parsing // ============================================================ async function parseEbookOnClient(file: Blob, format: string): Promise { switch (format) { case 'epub': return parseEPUB(file); case 'fb2': return parseFB2(file); case 'txt': return parseTXT(file); case 'html': return parseHTML(file); default: throw new Error(`Client-side parser not implemented for: ${format}`); } } // ============================================================ // Server-Side Parsing (API Call) // ============================================================ async function parseEbookOnServer(file: Blob, format: string): Promise { const formData = new FormData(); formData.append('file', file); formData.append('format', format); const response = await fetch('/api/readers/parse', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`Server parsing failed: ${response.statusText}`); } return await response.json(); } ``` --- ### 5.2 EPUB Parser (Refactored - Procedural) **File:** `web/src/reader/parsers/epub-parser.ts` ```typescript // EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Main Parse Function // ============================================================ export async function parseEPUB(epubBlob: Blob): Promise { const zip = await JSZip.loadAsync(epubBlob); // Parse container.xml to find OPF file const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const opfPath = extractOPFPath(containerXml); if (!opfPath) { throw new Error('Invalid EPUB: no OPF file found'); } // Parse OPF file const opfXml = await getZipFileContent(zip, opfPath); const packageDoc = parseXML(opfXml); // Extract all components const metadata = extractMetadata(packageDoc); const spine = parseSpine(packageDoc); const toc = await parseTOC(zip, packageDoc, opfPath); const resources = await loadResources(zip); const coverImage = await extractCover(zip, packageDoc); // Calculate locations (minimal - backend handles detailed tracking) const totalCharacters = await calculateTotalCharacters(spine, resources); return { metadata, toc, spine, resources, locations: { totalCharacters, estimatedPages: Math.ceil(totalCharacters / 1500), }, }; } // ============================================================ // Helper Functions // ============================================================ async function getZipFileContent(zip: JSZip, path: string): Promise { const file = zip.file(path); if (!file) { throw new Error(`File not found: ${path}`); } return await file.async('text'); } function parseXML(xmlString: string): XMLDocument { const parser = new DOMParser(); return parser.parseFromString(xmlString, 'text/xml'); } function extractOPFPath(containerXml: string): string | null { const containerDoc = parseXML(containerXml); return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null; } function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] { const metadata = packageDoc.querySelector('metadata'); if (!metadata) { throw new Error('No metadata found in OPF'); } return { title: metadata.querySelector('title')?.textContent || '', author: metadata.querySelector('creator')?.textContent || '', language: metadata.querySelector('language')?.textContent || 'en', publisher: metadata.querySelector('publisher')?.textContent || undefined, isbn: metadata.querySelector('identifier')?.textContent || undefined, }; } function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] { const spine = packageDoc.querySelector('spine'); const manifest = packageDoc.querySelector('manifest'); if (!spine || !manifest) { throw new Error('No spine or manifest found in OPF'); } const spineItems = spine.querySelectorAll('itemref'); const result: EbookCIF['spine'] = []; spineItems.forEach((itemref) => { const idref = itemref.getAttribute('idref'); if (!idref) return; const manifestItem = manifest.querySelector(`[id="${idref}"]`); if (!manifestItem) return; const href = manifestItem.getAttribute('href'); if (!href) return; result.push({ id: idref, type: 'html', content: href, properties: itemref.getAttribute('properties') || undefined, }); }); return result; } async function parseTOC(zip: JSZip, packageDoc: XMLDocument, opfPath: string): Promise { // Try EPUB 3.0 navigation document first const navItem = packageDoc.querySelector('manifest item[properties~="nav"]'); if (navItem) { const navHref = navItem.getAttribute('href'); if (navHref) { const navPath = resolvePath(opfPath, navHref); return parseNavTOC(zip, navPath); } } // Fallback to EPUB 2.0 NCX const ncxId = spine?.getAttribute('toc'); if (ncxId) { const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`); if (ncxItem) { const ncxHref = ncxItem.getAttribute('href'); if (ncxHref) { const ncxPath = resolvePath(opfPath, ncxHref); return parseNCXTOC(zip, ncxPath); } } } return []; } async function parseNavTOC(zip: JSZip, navPath: string): Promise { const navXml = await getZipFileContent(zip, navPath); const navDoc = parseXML(navXml); const nav = navDoc.querySelector('nav'); if (!nav) return []; const ol = nav.querySelector('ol'); if (!ol) return []; const items = ol.querySelectorAll(':scope > li'); const result: EbookCIF['toc'] = []; for (const li of items) { const link = li.querySelector('a'); if (link) { result.push({ id: link.getAttribute('href') || '', title: link.textContent || '', href: link.getAttribute('href') || '', children: [], }); } } return result; } async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise { const ncxXml = await getZipFileContent(zip, ncxPath); const ncxDoc = parseXML(ncxXml); const navMap = ncxDoc.querySelector('navMap'); if (!navMap) return []; return parseNCXNode(navMap); } function parseNCXNode(node: Element): EbookCIF['toc'] { const navPoints = node.querySelectorAll(':scope > navPoint'); const result: EbookCIF['toc'] = []; navPoints.forEach((navPoint) => { const label = navPoint.querySelector('navLabel text')?.textContent || ''; const content = navPoint.querySelector('content'); const href = content?.getAttribute('src') || ''; result.push({ id: href, title: label, href, children: parseNCXNode(navPoint), }); }); return result; } async function loadResources(zip: JSZip): Promise> { const resources = new Map(); const files = Object.keys(zip.files); for (const path of files) { const file = zip.file(path); if (file && !file.dir) { const blob = await file.async('blob'); resources.set(path, blob); } } return resources; } async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise { // Try cover-id metadata const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content'); if (coverId) { const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`); if (coverItem) { const coverHref = coverItem.getAttribute('href'); if (coverHref) { const coverFile = zip.file(coverHref); if (coverFile) { return await coverFile.async('blob'); } } } } // Fallback: look for cover image in manifest const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]'); if (coverItem) { const coverHref = coverItem.getAttribute('href'); if (coverHref) { const coverFile = zip.file(coverHref); if (coverFile) { return await coverFile.async('blob'); } } } return undefined; } function resolvePath(basePath: string, relativePath: string): string { const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); return baseDir + relativePath; } async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map): Promise { let total = 0; for (const item of spine) { if (item.type === 'html') { const content = resources.get(item.content); if (content) { const text = await content.text(); total += text.length; } } } return total; } function resolvePath(basePath: string, relativePath: string): string { const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); return baseDir + relativePath; } } } return total; } function generatePageBreaks(totalCharacters: number): number[] { const breaks: number[] = []; const charsPerPage = 1000; // Rough estimate for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) { breaks.push(i); } return breaks; } // ============================================================ // Metadata Quick Extract (for library view) // ============================================================ export async function extractEPUBMetadata(epubBlob: Blob): Promise> { const zip = await JSZip.loadAsync(epubBlob); const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const opfPath = extractOPFPath(containerXml); if (!opfPath) { return {}; } const opfXml = await getZipFileContent(zip, opfPath); const packageDoc = parseXML(opfXml); return extractMetadata(packageDoc); } ``` --- ### 5.3 FictionBook 2 (FB2) Parser **File:** `web/src/reader/parsers/fb2-parser.ts` ```typescript // FB2 Parser - Converts FictionBook 2 to Common Intermediate Format // FB2 is XML-based, similar to EPUB structure // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Main Parse Function // ============================================================ export async function parseFB2(fb2Blob: Blob): Promise { // FB2 can be plain XML or zipped (.fb2.zip) let xmlContent: string; if (fb2Blob.type === 'application/zip' || fb2Blob.type === 'application/x-zip-compressed') { const zip = await JSZip.loadAsync(fb2Blob); const files = Object.keys(zip.files); // Find the first .fb2 file in the zip const fb2File = files.find(f => f.endsWith('.fb2')); if (!fb2File) { throw new Error('No .fb2 file found in archive'); } xmlContent = await zip.file(fb2File)!.async('text'); } else { xmlContent = await fb2Blob.text(); } const xmlDoc = parseXML(xmlContent); const metadata = extractFB2Metadata(xmlDoc); const toc = parseFB2TOC(xmlDoc); const spine = createFB2Spine(xmlDoc); const resources = await extractFB2Resources(xmlDoc, fb2Blob); // Calculate locations (minimal - backend handles detailed tracking) const totalCharacters = calculateFB2Characters(xmlDoc); return { metadata, toc, spine, resources, locations: { totalCharacters, estimatedPages: Math.ceil(totalCharacters / 1500), }, }; } // ============================================================ // Helper Functions // ============================================================ function parseXML(xmlString: string): XMLDocument { const parser = new DOMParser(); return parser.parseFromString(xmlString, 'text/xml'); } function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF['metadata'] { const titleInfo = xmlDoc.querySelector('title-info'); const documentInfo = xmlDoc.querySelector('document-info'); if (!titleInfo) { throw new Error('Invalid FB2: no title-info found'); } return { title: titleInfo.querySelector('book-title')?.textContent || '', author: extractFB2Author(titleInfo), language: titleInfo.querySelector('lang')?.textContent || 'en', publisher: documentInfo?.querySelector('publisher')?.textContent || undefined, isbn: undefined, // FB2 doesn't typically have ISBN }; } function extractFB2Author(titleInfo: Element): string { const author = titleInfo.querySelector('author'); if (!author) return ''; const firstName = author.querySelector('first-name')?.textContent || ''; const lastName = author.querySelector('last-name')?.textContent || ''; const middleName = author.querySelector('middle-name')?.textContent || ''; const parts = [firstName, middleName, lastName].filter(Boolean); return parts.join(' ') || 'Unknown'; } function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF['toc'] { const toc: EbookCIF['toc'] = []; const body = xmlDoc.querySelector('body'); if (!body) return toc; const sections = body.querySelectorAll(':scope > section'); let sectionIndex = 0; for (const section of sections) { const title = section.querySelector('title'); const titleText = title?.textContent.trim() || `Section ${sectionIndex + 1}`; toc.push({ id: `section-${sectionIndex}`, title: titleText, href: `#section-${sectionIndex}`, children: [], }); sectionIndex++; } return toc; } function createFB2Spine(xmlDoc: XMLDocument): EbookCIF['spine'] { const spine: EbookCIF['spine'] = []; const body = xmlDoc.querySelector('body'); if (!body) return spine; // Convert each section to HTML const sections = body.querySelectorAll(':scope > section'); sections.forEach((section, index) => { const htmlContent = convertFB2SectionToHTML(section, index); spine.push({ id: `section-${index}`, type: 'html', content: htmlContent, index, }); }); return spine; } function convertFB2SectionToHTML(section: Element, index: number): string { const title = section.querySelector('title'); let html = `
`; if (title) { html += `

${title.textContent}

`; } // Convert paragraphs const paragraphs = section.querySelectorAll('p'); paragraphs.forEach(p => { html += `

${p.innerHTML}

`; }); // Convert images const images = section.querySelectorAll('image'); images.forEach(img => { const href = img.getAttribute('l:href'); const alt = img.getAttribute('alt') || ''; if (href) { html += `${alt}`; } }); html += '
'; return html; } async function extractFB2Resources(xmlDoc: XMLDocument, fb2Blob: Blob): Promise> { const resources = new Map(); // FB2 can have embedded images (base64) or external references const binary = xmlDoc.querySelector('binary'); if (binary) { const contentType = binary.getAttribute('content-type'); const id = binary.getAttribute('id'); if (contentType && id && binary.textContent) { // Decode base64 const base64Data = binary.textContent.trim(); const byteString = atob(base64Data); const byteArray = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { byteArray[i] = byteString.charCodeAt(i); } const blob = new Blob([byteArray], { type: contentType }); resources.set(`#${id}`, blob); } } return resources; } function calculateFB2Characters(xmlDoc: XMLDocument): number { const body = xmlDoc.querySelector('body'); if (!body) return 0; return body.textContent?.length || 0; } function generatePageBreaks(totalCharacters: number): number[] { const breaks: number[] = []; const charsPerPage = 1000; for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) { breaks.push(i); } return breaks; } // ============================================================ // Metadata Quick Extract // ============================================================ export async function extractFB2Metadata(fb2Blob: Blob): Promise> { let xmlContent: string; if (fb2Blob.type === 'application/zip') { const zip = await JSZip.loadAsync(fb2Blob); const files = Object.keys(zip.files); const fb2File = files.find(f => f.endsWith('.fb2')); if (!fb2File) return {}; xmlContent = await zip.file(fb2File)!.async('text'); } else { xmlContent = await fb2Blob.text(); } const xmlDoc = parseXML(xmlContent); return extractFB2Metadata(xmlDoc); } ``` --- ### 5.4 Plain Text (TXT) Parser **File:** `web/src/reader/parsers/txt-parser.ts` ```typescript // TXT Parser - Wraps plain text in HTML structure // Procedural style: Functions, not classes // ============================================================ // Main Parse Function // ============================================================ export async function parseTXT(txtBlob: Blob): Promise { const textContent = await txtBlob.text(); const metadata = extractTXTMetadata(txtBlob); const toc = createTXTTOC(textContent); const spine = createTXTSpine(textContent); const resources = new Map(); // No external resources for plain text const totalCharacters = textContent.length; return { metadata, toc, spine, resources, locations: { totalCharacters, estimatedPages: Math.ceil(totalCharacters / 1500), }, }; } // ============================================================ // Helper Functions // ============================================================ function extractTXTMetadata(txtBlob: Blob): EbookCIF['metadata'] { const filename = txtBlob.name || 'Unknown'; return { title: filename.replace(/\.(txt|text)$/i, ''), author: 'Unknown', language: 'en', }; } function createTXTTOC(textContent: string): EbookCIF['toc'] { // Try to detect chapters (simple heuristic) const toc: EbookCIF['toc'] = []; const lines = textContent.split('\n'); let chapterIndex = 0; lines.forEach((line, index) => { // Common chapter patterns const chapterPattern = /^(chapter|part|section)\s+\d+/i; if (chapterPattern.test(line.trim())) { toc.push({ id: `chapter-${chapterIndex}`, title: line.trim(), href: `#chapter-${chapterIndex}`, children: [], }); chapterIndex++; } }); // If no chapters found, create single entry if (toc.length === 0) { toc.push({ id: 'full-text', title: 'Full Text', href: '#full-text', children: [], }); } return toc; } function createTXTSpine(textContent: string): EbookCIF['spine'] { // Convert plain text to HTML paragraphs const lines = textContent.split('\n'); let htmlContent = '
'; lines.forEach(line => { const trimmed = line.trim(); if (trimmed) { htmlContent += `

${escapeHTML(trimmed)}

`; } else { htmlContent += '
'; } }); htmlContent += '
'; return [{ id: 'full-text', type: 'html', content: htmlContent, index: 0, }]; } function escapeHTML(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Removed - backend handles detailed position tracking // ============================================================ // Metadata Quick Extract // ============================================================ export async function extractTXTMetadata(txtBlob: Blob): Promise> { return extractTXTMetadata(txtBlob); } ``` --- ### 5.5 HTML Parser **File:** `web/src/reader/parsers/html-parser.ts` ```typescript // HTML Parser - Wraps standalone HTML files // Procedural style: Functions, not classes // ============================================================ // Main Parse Function // ============================================================ export async function parseHTML(htmlBlob: Blob): Promise { const htmlContent = await htmlBlob.text(); const metadata = extractHTMLMetadata(htmlBlob, htmlContent); const toc = createHTMLTOC(htmlContent); const spine = createHTMLSpine(htmlContent); const resources = await extractHTMLResources(htmlBlob, htmlContent); const totalCharacters = stripHTML(htmlContent).length; const pageBreaks = generatePageBreaks(totalCharacters); return { metadata, toc, spine, resources, locations: { totalCharacters, pageBreaks, }, }; } // ============================================================ // Helper Functions // ============================================================ function extractHTMLMetadata(htmlBlob: Blob, htmlContent: string): EbookCIF['metadata'] { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); const title = doc.querySelector('title')?.textContent || htmlBlob.name.replace(/\.(html?|htm)$/i, ''); const metaAuthor = doc.querySelector('meta[name="author"]')?.getAttribute('content'); const metaLang = doc.querySelector('html')?.getAttribute('lang') || 'en'; return { title, author: metaAuthor || 'Unknown', language: metaLang, }; } function createHTMLTOC(htmlContent: string): EbookCIF['toc'] { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); const toc: EbookCIF['toc'] = []; // Try to find headings const headings = doc.querySelectorAll('h1, h2, h3'); let headingIndex = 0; headings.forEach(heading => { toc.push({ id: `heading-${headingIndex}`, title: heading.textContent || '', href: `#${heading.id || `heading-${headingIndex}`}`, children: [], }); headingIndex++; }); // If no headings, create single entry if (toc.length === 0) { toc.push({ id: 'full-document', title: 'Full Document', href: '#full-document', children: [], }); } return toc; } function createHTMLSpine(htmlContent: string): EbookCIF['spine'] { return [{ id: 'full-document', type: 'html', content: htmlContent, index: 0, }]; } async function extractHTMLResources(htmlBlob: Blob, htmlContent: string): Promise> { const resources = new Map(); const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); // Extract images const images = doc.querySelectorAll('img[src]'); for (const img of Array.from(images)) { const src = img.getAttribute('src'); if (!src) continue; // Try to resolve relative URLs if (src.startsWith('data:')) { // Data URI - extract blob const match = src.match(/^data:([^;]+);base64,(.+)$/); if (match) { const mimeType = match[1]; const base64 = match[2]; const byteString = atob(base64); const byteArray = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { byteArray[i] = byteString.charCodeAt(i); } const blob = new Blob([byteArray], { type: mimeType }); resources.set(src, blob); } } // External resources would need to be fetched // For now, skip them (browser will load them naturally) } return resources; } function stripHTML(html: string): string { const div = document.createElement('div'); div.innerHTML = html; return div.textContent || ''; } // ============================================================ // Metadata Quick Extract // ============================================================ export async function extractHTMLMetadata(htmlBlob: Blob): Promise> { const htmlContent = await htmlBlob.text(); return extractHTMLMetadata(htmlBlob, htmlContent); } ``` --- ### 5.6 HTML Renderer (Procedural) **File:** `web/src/reader/ebook/html-renderer.ts` ```typescript // HTML rendering with theme support, font loading, and image handling // Procedural style: Functions, not classes interface RendererConfig { readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; fontSize: number; lineHeight: number; marginWidth: number; textAlign: 'left' | 'justify'; columnCount: 1 | 2; } // ============================================================ // Main Render Function // ============================================================ export async function renderHTMLDocument( doc: HTMLDocument, container: HTMLElement, config: RendererConfig ): Promise { // Apply theme applyHTMLTheme(container, config.readingTheme); // Apply typography settings applyHTMLTypography(container, config); // Inject custom styles for reader injectHTMLReaderStyles(container); // Handle embedded fonts await loadEmbeddedHTMLFonts(doc, container); // Handle images processHTMLImages(doc, container); // Clear container and append content container.innerHTML = ''; container.appendChild(doc.body); // Apply column layout applyHTMLColumnLayout(container, config.columnCount); } // ============================================================ // Theme Application // ============================================================ function applyHTMLTheme(container: HTMLElement, theme: string): void { const readingThemes: Record> = { 'light': { '--bg-primary': '#ffffff', '--text-primary': '#1a1a1a', '--text-secondary': '#666666', '--accent': '#0066cc' }, 'sepia': { '--bg-primary': '#f4ecd8', '--text-primary': '#5f4b32', '--text-secondary': '#8b7355', '--accent': '#8b4513' }, 'dark': { '--bg-primary': '#1a1b26', '--text-primary': '#c0caf5', '--text-secondary': '#565f89', '--accent': '#7aa2f7' }, 'night': { '--bg-primary': '#0d1117', '--text-primary': '#c9d1d9', '--text-secondary': '#8b949e', '--accent': '#58a6ff' }, 'high-contrast': { '--bg-primary': '#000000', '--text-primary': '#ffffff', '--text-secondary': '#cccccc', '--accent': '#ffff00' } }; const themeConfig = readingThemes[theme] || readingThemes['dark']; for (const [key, value] of Object.entries(themeConfig)) { container.style.setProperty(key, value); } } function applyHTMLTypography(container: HTMLElement, config: RendererConfig): void { const style = document.createElement('style'); const fontStack = getFontStack(config.readingFont); style.textContent = ` .ebook-content { font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; padding: 0 ${config.marginWidth}px; max-width: 100%; overflow-wrap: break-word; } .ebook-content p { margin-bottom: 1em; text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; } .ebook-content img { max-width: 100%; height: auto; display: block; margin: 1em auto; } .ebook-content a { color: var(--accent); text-decoration: underline; } .ebook-content a:active { color: var(--text-secondary); } `; container.appendChild(style); } function injectHTMLReaderStyles(container: HTMLElement): void { container.setAttribute('role', 'main'); container.setAttribute('aria-label', 'Book content'); } async function loadEmbeddedHTMLFonts(doc: HTMLDocument, container: HTMLElement): Promise { const styleSheets = doc.querySelectorAll('style'); for (const sheet of styleSheets) { const fontFaceRegex = /@font-face\s*{([^}]+)}/g; const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; for (const match of matches) { const fontFace = match[1]; const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); if (urlMatch) { const fontUrl = urlMatch[1]; await loadHTMLFont(fontUrl, container); } } } } async function loadHTMLFont(fontUrl: string, container: HTMLElement): Promise { const loadedFonts = container.dataset.loadedFonts ? JSON.parse(container.dataset.loadedFonts) : []; if (loadedFonts.includes(fontUrl)) return; try { const fontFace = new FontFace('custom-font', `url(${fontUrl})`); await fontFace.load(); document.fonts.add(fontFace); loadedFonts.push(fontUrl); container.dataset.loadedFonts = JSON.stringify(loadedFonts); } catch (error) { console.error('Failed to load font:', fontUrl, error); } } function processHTMLImages(doc: HTMLDocument): void { const images = doc.querySelectorAll('img'); images.forEach((img) => { img.setAttribute('loading', 'lazy'); if (!img.alt) { img.alt = 'Image from book'; } img.style.cursor = 'pointer'; img.addEventListener('click', () => { showImageFullscreen(img.src); }); }); } function showImageFullscreen(src: string): void { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; modal.onclick = () => modal.remove(); const img = document.createElement('img'); img.src = src; img.className = 'max-w-full max-h-full object-contain'; modal.appendChild(img); document.body.appendChild(modal); } function applyHTMLColumnLayout(container: HTMLElement, columnCount: number): void { if (columnCount === 2) { container.style.columnCount = '2'; container.style.columnGap = '20px'; container.style.columnRule = '1px solid var(--text-secondary)'; } else { container.style.columnCount = 'auto'; } } function getFontStack(font: string): string { const stacks: Record = { 'literata': '"Literata", serif', 'crimson': '"Crimson Text", serif', 'source-serif': '"Source Serif 4", serif', 'eb-garamond': '"EB Garamond", serif', 'libertinus': '"Libertinus Serif", serif', 'noto-serif': '"Noto Serif", serif', 'charis-sil': '"Charis SIL", serif', 'ibm-plex': '"IBM Plex Serif", serif', }; return stacks[font] || stacks['literata']; } ``` --- ### 5.7 CFI Navigation (Procedural) **File:** `web/src/reader/ebook/cfi-navigator.ts` ```typescript // EPUB CFI (Canonical Fragment Identifier) navigation // Reuses logic from internal/sync/format.go // Procedural style: Functions, not classes interface CFIComponent { type: 'index' | 'indirection-step' | 'text-location'; value: number; id?: string; textOffset?: number; } // ============================================================ // CFI Parsing Functions // ============================================================ export function parseCFI(cfi: string): CFIComponent[] { const components: CFIComponent[] = []; const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi; const parts = cleanCFI.split('/').filter(Boolean); for (const part of parts) { const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); if (match) { const component: CFIComponent = { type: match[3] !== undefined ? 'text-location' : 'index', value: parseInt(match[1], 10), id: match[2], textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined }; components.push(component); } } return components; } export function generateCFI( spineIndex: number, elementPath: number[], textOffset: number = 0, spineItemId?: string ): string { let cfi = `/6/${spineIndex}`; if (spineItemId) { cfi += `[${spineItemId}]`; } for (const index of elementPath) { cfi += `/${index}`; } if (textOffset > 0) { cfi += `:${textOffset}`; } return cfi; } export function navigateToCFI(doc: Document, cfi: string): Element | Text | null { const components = parseCFI(cfi); if (components.length === 0) return null; let current: Node | null = doc.body; for (let i = 1; i < components.length; i++) { const component = components[i]; if (component.type === 'index') { if (current instanceof Element) { const children = getElementChildren(current); current = children[component.value] || null; } } } return current as Element | Text; } export function getSelectionCFI(doc: Document): string | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const startContainer = range.startContainer; // Build path to start container const path: number[] = []; let current: Node | null = startContainer; while (current && current !== doc.body) { const parent = current.parentElement; if (parent) { const siblings = getElementChildren(parent); const index = siblings.indexOf(current as Element); path.unshift(index); } current = parent; } const spineIndex = 0; const textOffset = range.startOffset; return generateCFI(spineIndex, path, textOffset); } export function getPercentageFromCFI(cfi: string): number { const components = parseCFI(cfi); const textLocation = components.find(c => c.type === 'text-location'); if (textLocation && textLocation.textOffset !== undefined) { return Math.min((textLocation.textOffset / 10), 100); } return 0; } export function compareCFIs(cfi1: string, cfi2: string): number { const components1 = parseCFI(cfi1); const components2 = parseCFI(cfi2); const maxLen = Math.max(components1.length, components2.length); for (let i = 0; i < maxLen; i++) { const comp1 = components1[i]; const comp2 = components2[i]; if (!comp1) return -1; if (!comp2) return 1; if (comp1.value !== comp2.value) { return comp1.value - comp2.value; } } return 0; } function getElementChildren(element: Element): Element[] { return Array.from(element.children).filter(el => el.nodeType === Node.ELEMENT_NODE ) as Element[]; } ``` --- ### 5.8 Typography Engine (Procedural) **File:** `web/src/reader/ebook/typography-engine.ts` ```typescript // Typography engine for ebook rendering // Procedural style: Functions, not classes interface TypographyConfig { fontSize: number; lineHeight: number; textAlign: 'left' | 'justify'; hyphenate: boolean; ligatures: boolean; fontSmoothing: 'auto' | 'grayscale'; } export function applyTypographyConfig( element: HTMLElement, config: TypographyConfig ): void { // Enable/disable ligatures setLigatures(element, config.ligatures); // Enable/disable hyphenation if (config.hyphenate) { enableHyphenation(element); } // Apply justification settings if (config.textAlign === 'justify') { enableJustification(element); } // Apply font smoothing element.style.fontSmooth = config.fontSmoothing; } function setLigatures(element: HTMLElement, enabled: boolean): void { if (enabled) { element.style.fontVariantLigatures = 'common-ligatures'; element.style.fontFeatureSettings = '"liga", "dlig"'; } else { element.style.fontVariantLigatures = 'no-common-ligatures'; element.style.fontFeatureSettings = 'normal'; } } function enableHyphenation(element: HTMLElement): void { element.style.hyphens = 'auto'; element.style.hyphenateLimitChars = '6 3 3'; // Add language attribute from EPUB metadata const lang = element.closest('[data-language]')?.getAttribute('data-language') || 'en'; element.setAttribute('lang', lang); } function enableJustification(element: HTMLElement): void { element.style.wordBreak = 'normal'; element.style.overflowWrap = 'break-word'; element.style.wordWrap = 'break-word'; element.style.letterSpacing = '0.01em'; } export function measureReadingTime( container: HTMLElement, wordsPerMinute: number = 250 ): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; const words = text.split(/\s+/).length; const minutes = words / wordsPerMinute; return Math.ceil(minutes); } export function getWordCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; return text.split(/\s+/).length; } ``` --- ### 5.9 Ebook Search (Procedural) **File:** `web/src/reader/ebook/search.ts` ```typescript // Search within ebook content // Procedural style: Functions, not classes interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } interface EbookSearchConfig { epubPackage: EPUBPackage; } // ============================================================ // Main Search Function // ============================================================ export async function searchEbook( epubPackage: EPUBPackage, query: string ): Promise { const results: SearchResult[] = []; const lowerQuery = query.toLowerCase(); // Search all spine items for (const [index, spineItem] of epubPackage.spine.entries()) { const doc = await getSpineItemDocument(epubPackage, spineItem); if (!doc) continue; const chapterTitle = getChapterTitle(spineItem); // Search in text nodes const textNodes = findTextNodes(doc.body); for (const node of textNodes) { const text = node.textContent || ''; const lowerText = text.toLowerCase(); let foundAt = 0; while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { const cfi = generateCFIForNode(node, foundAt); const snippet = extractSnippet(text, foundAt, query.length); results.push({ cfi, snippet, chapterTitle }); foundAt += lowerQuery.length; } } } return results; } async function getSpineItemDocument( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): Promise { try { const content = await epubPackage.resources.get(spineItem.href)?.text(); if (!content) return null; const parser = new DOMParser(); return parser.parseFromString(content, 'text/html'); } catch (error) { console.error('Failed to load spine item:', spineItem.href, error); return null; } } function getChapterTitle(spineItem: EPUBSpineItem): string { // Extract title from spine item or use default return spineItem.id || `Section ${spineItem.index}`; } function findTextNodes(root: Node): Text[] { const textNodes: Text[] = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { const parent = node.parentElement; if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { return NodeFilter.FILTER_REJECT; } if (!node.textContent?.trim()) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); let node: Node | null; while ((node = walker.nextNode())) { textNodes.push(node as Text); } return textNodes; } function generateCFIForNode(node: Text, offset: number): string { const path: number[] = []; let current: Node | null = node; while (current && current.parentNode) { const parent = current.parentNode; const siblings = Array.from(parent.childNodes) .filter(n => n.nodeType === Node.ELEMENT_NODE); const index = siblings.indexOf(current as Node); path.unshift(index); current = parent; } const spineIndex = 0; // Would come from parent context return generateCFI(spineIndex, path, offset); } function extractSnippet(text: string, offset: number, length: number): string { const contextBefore = 30; const contextAfter = 50; const start = Math.max(0, offset - contextBefore); const end = Math.min(text.length, offset + length + contextAfter); return text.slice(start, end); } ``` **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 } // HTML rendering with theme support, font loading, and image handling // Procedural implementation (no OOP) const loadedFonts = new Set(); async function renderDocument( container: HTMLElement, doc: HTMLDocument, config: RendererConfig ): Promise { applyTheme(container, config.readingTheme); applyTypography(container, config); injectReaderStyles(container); await loadEmbeddedFonts(doc); processImages(doc); container.innerHTML = ''; container.appendChild(doc.body); applyColumnLayout(container, config); } function applyTheme( container: HTMLElement, readingTheme: RendererConfig['readingTheme'] ): void { 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[readingTheme] || readingThemes['dark']; for (const [key, value] of Object.entries(theme)) { container.style.setProperty(key, value); } } function applyTypography(container: HTMLElement, config: RendererConfig): void { const style = document.createElement('style'); const fontStack = getFontStack(config.readingFont); style.textContent = ` .ebook-content { font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; padding: 0 ${config.marginWidth}px; max-width: 100%; overflow-wrap: break-word; } .ebook-content p { margin-bottom: 1em; text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; } .ebook-content img { max-width: 100%; height: auto; display: block; margin: 1em auto; } .ebook-content a { color: var(--accent); text-decoration: underline; } .ebook-content a:active { color: var(--text-secondary); } `; container.appendChild(style); } function injectReaderStyles(container: HTMLElement): void { container.setAttribute('role', 'main'); container.setAttribute('aria-label', 'Book content'); } async function loadEmbeddedFonts(doc: HTMLDocument): Promise { 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 loadFont(fontUrl); } } } } async function loadFont(fontUrl: string): Promise { if (loadedFonts.has(fontUrl)) return; try { const fontFace = new FontFace('custom-font', `url(${fontUrl})`); await fontFace.load(); document.fonts.add(fontFace); loadedFonts.add(fontUrl); } catch (error) { console.error('Failed to load font:', fontUrl, error); } } function processImages(doc: HTMLDocument): void { const images = doc.querySelectorAll('img'); images.forEach((img) => { img.setAttribute('loading', 'lazy'); if (!img.alt) { img.alt = 'Image from book'; } img.style.cursor = 'pointer'; img.addEventListener('click', () => { showImageFullscreen(img.src); }); }); } function showImageFullscreen(src: string): void { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; modal.onclick = () => modal.remove(); const img = document.createElement('img'); img.src = src; img.className = 'max-w-full max-h-full object-contain'; modal.appendChild(img); document.body.appendChild(modal); } function applyColumnLayout(container: HTMLElement, config: RendererConfig): void { if (config.columnCount === 2) { container.style.columnCount = '2'; container.style.columnGap = `${config.marginWidth}px`; container.style.columnRule = '1px solid var(--text-secondary)'; } else { container.style.columnCount = 'auto'; } } function updateRendererConfig( container: HTMLElement, currentConfig: RendererConfig, newConfig: Partial ): RendererConfig { const updatedConfig = { ...currentConfig, ...newConfig }; const currentDoc = container.querySelector('.ebook-content')?.ownerDocument; if (currentDoc) { renderDocument(container, currentDoc as HTMLDocument, updatedConfig); } return updatedConfig; } ``` ### 5.10 Libre Reading Fonts (Bundled) **8 Open Source Fonts Optimized for Extended Reading** All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i). **Font Directory:** `web/static/fonts/` #### 5.10.1 Font Acquisition & Installation **Automated Setup Script** **File:** `scripts/fetch-reading-fonts.sh` (new file) ```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.10.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.10.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.10.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.10.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.10.6 Font Subsetting for Language Support **Full Unicode vs. Latin-1 Subset:** - **Full Unicode**: ~200KB per style (supports all languages) - **Latin-1 Subset**: ~50KB per style (supports Western European languages) **Recommendation:** Bundle full Unicode for most fonts, but subset Noto Serif to Latin-1 unless you need extensive language support. **Subset Noto Serif (Latin-1 only):** ```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.10.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.11 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'; } // Typography engine for ebook text rendering // Procedural implementation (no OOP) interface TypographyConfig { readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; fontSize: number; lineHeight: number; textAlign: 'left' | 'justify'; marginTop: number; marginBottom: number; marginLeft: number; marginRight: number; textIndent: number; fontSmoothing: 'antialiased' | 'auto' | 'grayscale'; hyphenate: boolean; ligatures: boolean; } function applyTypography(container: HTMLElement, config: TypographyConfig): void { const content = container.querySelector('.ebook-content'); if (!content) return; const fontStack = getFontStack(config.readingFont); content.setAttribute('style', ` font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; margin-top: ${config.marginTop}px; margin-bottom: ${config.marginBottom}px; margin-left: ${config.marginLeft}px; margin-right: ${config.marginRight}px; text-indent: ${config.textIndent}px; -webkit-font-smoothing: ${config.fontSmoothing}; -moz-osx-font-smoothing: ${config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'}; `); if (config.hyphenate) { enableHyphenation(container, content as HTMLElement); } setLigatures(content as HTMLElement, config.ligatures); if (config.textAlign === 'justify') { enableJustification(content as HTMLElement); } } function enableHyphenation(container: HTMLElement, element: HTMLElement): void { element.style.hyphens = 'auto'; element.style.hyphenateLimitChars = '6 3 3'; const lang = container.closest('[data-language]')?.getAttribute('data-language') || 'en'; element.setAttribute('lang', lang); } function setLigatures(element: HTMLElement, enabled: boolean): void { if (enabled) { element.style.fontVariantLigatures = 'common-ligatures'; element.style.fontFeatureSettings = '"liga", "dlig"'; } else { element.style.fontVariantLigatures = 'no-common-ligatures'; element.style.fontFeatureSettings = 'normal'; } } function enableJustification(element: HTMLElement): void { element.style.wordBreak = 'normal'; element.style.overflowWrap = 'break-word'; element.style.wordWrap = 'break-word'; element.style.letterSpacing = '0.01em'; } function updateTypographyConfig( currentConfig: TypographyConfig, newConfig: Partial ): TypographyConfig { return { ...currentConfig, ...newConfig }; } function measureReadingTime(container: HTMLElement, wordsPerMinute: number = 250): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; const words = text.split(/\s+/).length; const minutes = words / wordsPerMinute; return Math.ceil(minutes); } function getWordCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; return text.split(/\s+/).length; } ``` ### 5.12 Search Within Ebook **File:** `web/src/reader/ebook/search.ts` ```typescript // Search within ebook content interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } // Search within ebook content // Procedural implementation (no OOP) interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } async function searchEbook( epubPackage: EPUBPackage, query: string ): Promise { const results: SearchResult[] = []; const lowerQuery = query.toLowerCase(); for (const [index, spineItem] of epubPackage.spine.entries()) { const doc = await getSpineItemDocument(epubPackage, spineItem); if (!doc) continue; const chapterTitle = getChapterTitle(epubPackage, spineItem); const textNodes = findTextNodes(doc.body); for (const node of textNodes) { const text = node.textContent || ''; const lowerText = text.toLowerCase(); let foundAt = 0; while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { const cfi = generateSearchCFI(node, foundAt); const snippet = extractSearchSnippet(text, foundAt, query.length); results.push({ cfi, snippet, chapterTitle }); foundAt += lowerQuery.length; } } } return results; } async function getSpineItemDocument( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): Promise { try { const content = await epubPackage.resources.get(spineItem.href)?.text(); if (!content) return null; const parser = new DOMParser(); return parser.parseFromString(content, 'text/html'); } catch (error) { console.error('Failed to load spine item:', spineItem.href, error); return null; } } function findTextNodes(root: Node): Text[] { const textNodes: Text[] = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { const parent = node.parentElement; if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { return NodeFilter.FILTER_REJECT; } if (!node.textContent?.trim()) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); let node: Node | null; while ((node = walker.nextNode())) { textNodes.push(node as Text); } return textNodes; } function generateSearchCFI(node: Text, offset: number): string { const path: number[] = []; let current: Node | null = node; while (current && current.parentNode) { const parent = current.parentNode; const siblings = Array.from(parent.childNodes) .filter(n => n.nodeType === Node.ELEMENT_NODE); const index = siblings.indexOf(current as Node); path.unshift(index); current = parent; } const spineIndex = 0; return generateCFI(spineIndex, path, offset); } function extractSearchSnippet(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); if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; return snippet; } function getChapterTitle( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): string { for (const toc of epubPackage.toc) { if (toc.href === spineItem.href) { return toc.label; } for (const child of toc.children) { if (child.href === spineItem.href) { return child.label; } } } return 'Chapter ' + (epubPackage.spine.indexOf(spineItem) + 1); } ``` ### 5.13 Copy Text Handler **File:** `web/src/reader/ebook/copy-handler.ts` ```typescript // Handle text copying with citation // Handle text copying with citation // Procedural implementation (no OOP) async function copySelection(mediaItem: MediaItemSummary): Promise { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const selectedText = selection.toString(); if (!selectedText.trim()) return false; const citation = createCitation(selectedText, mediaItem); try { await navigator.clipboard.writeText(citation); showToast('Copied to clipboard', 'success'); return true; } catch (error) { console.error('Failed to copy:', error); showToast('Failed to copy to clipboard', 'error'); return false; } } function createCitation(text: string, mediaItem: MediaItemSummary): string { let citation = `"${text}"\n`; citation += `— ${mediaItem.title}`; if (mediaItem.author) { citation += ` by ${mediaItem.author}`; } citation += `\n(Source: Bookhoard)`; return citation; } function enableContextMenuCopy(mediaItem: MediaItemSummary): void { document.addEventListener('contextmenu', async (e) => { const selection = window.getSelection(); const selectedText = selection?.toString().trim(); if (selectedText) { e.preventDefault(); await copySelection(mediaItem); } }); } ``` ### 5.14 View Modes **File:** `web/src/reader/ebook/view-modes.ts` ```typescript // Different viewing modes for ebooks type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; // Different viewing modes for ebooks // Procedural implementation (no OOP) type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; interface ViewModeState { currentMode: ViewMode; currentPage: number; } function setViewMode(container: HTMLElement, mode: ViewMode): void { const content = container.querySelector('.ebook-content'); if (!content) return; content.classList.remove( 'paginated', 'scrolled', 'single-column', 'double-column' ); switch (mode) { case 'paginated': applyPaginatedMode(container, content as HTMLElement); break; case 'scrolled': applyScrolledMode(container, content as HTMLElement); break; case 'single-column': applySingleColumn(content as HTMLElement); break; case 'double-column': applyDoubleColumn(content as HTMLElement); break; } } function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void { element.classList.add('paginated'); element.style.height = '100vh'; element.style.overflow = 'hidden'; element.style.columnCount = '1'; element.style.columnGap = '0'; enablePagination(container, element); } function applyScrolledMode(container: HTMLElement, element: HTMLElement): void { element.classList.add('scrolled'); element.style.height = 'auto'; element.style.overflowY = 'auto'; element.style.columnCount = '1'; disablePagination(container); } function applySingleColumn(element: HTMLElement): void { element.classList.add('single-column'); element.style.columnCount = '1'; element.style.columnGap = '0'; element.style.maxWidth = '800px'; element.style.margin = '0 auto'; } function applyDoubleColumn(element: HTMLElement): void { element.classList.add('double-column'); element.style.columnCount = '2'; element.style.columnGap = '60px'; element.style.columnRule = '1px solid var(--text-secondary)'; element.style.maxWidth = '1400px'; element.style.margin = '0 auto'; } function enablePagination(container: HTMLElement, element: HTMLElement): void { const totalHeight = element.scrollHeight; const pageHeight = element.clientHeight; const pageCount = Math.ceil(totalHeight / pageHeight); addPaginationControls(container, pageCount); } function disablePagination(container: HTMLElement): void { const controls = container.querySelector('.pagination-controls'); controls?.remove(); } function addPaginationControls(container: HTMLElement, pageCount: number): ViewModeState { let currentPage = 1; const controls = document.createElement('div'); controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t'; controls.innerHTML = ` Page ${currentPage} of ${pageCount} `; controls.querySelector('.prev-page')?.addEventListener('click', () => { if (currentPage > 1) { currentPage--; goToPage(container, currentPage); } }); controls.querySelector('.next-page')?.addEventListener('click', () => { if (currentPage < pageCount) { currentPage++; goToPage(container, currentPage); } }); container.appendChild(controls); return { currentMode: 'paginated', currentPage }; } function goToPage(container: HTMLElement, pageNumber: number): void { const content = container.querySelector('.ebook-content') as HTMLElement; if (!content) return; const pageHeight = content.clientHeight; const scrollTop = (pageNumber - 1) * pageHeight; content.scrollTo({ top: scrollTop, behavior: 'smooth' }); const pageInfo = container.querySelector('.page-info'); if (pageInfo) { pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`; } } function getTotalPageCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content') as HTMLElement; if (!content) return 1; const totalHeight = content.scrollHeight; const pageHeight = content.clientHeight; return Math.ceil(totalHeight / pageHeight); } ``` --- ## 6. PDF Reader Implementation ### 6.1 PDF.js Integration (Procedural) **File:** `web/src/reader/pdf/pdfjs-wrapper.ts` ```typescript // Mozilla pdf.js integration for PDF rendering // Procedural style: Functions, not classes import * as pdfjsLib from 'pdfjs-dist'; // ============================================================ // PDF.js Configuration // ============================================================ export function configurePDFJS(): void { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.mjs'; pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = '/static/standard_fonts/'; pdfjsLib.GlobalWorkerOptions.cMapUrl = '/static/cmaps/'; pdfjsLib.GlobalWorkerOptions.cMapPacked = true; } // ============================================================ // PDF Document State // ============================================================ interface PDFDocumentState { doc: pdfjsLib.PDFDocumentProxy | null; pages: Map; metadata: PDFMetadata | null; } interface PDFMetadata { title: string; author: string; subject?: string; keywords?: string; creator?: string; producer?: string; creationDate?: Date; modificationDate?: Date; pageCount: number; } let pdfState: PDFDocumentState = { doc: null, pages: new Map(), metadata: null, }; // ============================================================ // Document Loading // ============================================================ export async function loadPDFDocument(pdfBlob: Blob): Promise { // Cleanup previous document unloadPDFDocument(); const arrayBuffer = await pdfBlob.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer, }); pdfState.doc = await loadingTask.promise; // Extract metadata const metadata = await pdfState.doc.getMetadata().catch(() => null); const info = metadata?.info || {}; pdfState.metadata = { title: info.Title || 'Untitled', author: info.Author || 'Unknown', subject: info.Subject, keywords: info.Keywords, creator: info.Creator, producer: info.Producer, creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined, modificationDate: info.ModDate ? new Date(info.ModDate) : undefined, pageCount: pdfState.doc.numPages, }; return pdfState.metadata; } export async function getPDFPage(pageNumber: number): Promise { if (!pdfState.doc) { throw new Error('PDF document not loaded'); } // Check cache if (pdfState.pages.has(pageNumber)) { return pdfState.pages.get(pageNumber)!; } // Load page const page = await pdfState.doc.getPage(pageNumber); pdfState.pages.set(pageNumber, page); return page; } export async function getPDFPageText(pageNumber: number): Promise { const page = await getPDFPage(pageNumber); return await page.getTextContent(); } export function getPDFMetadata(): PDFMetadata | null { return pdfState.metadata; } export function getPDFPageCount(): number { return pdfState.doc?.numPages || 0; } export function unloadPDFDocument(): void { pdfState.pages.clear(); pdfState.doc = null; pdfState.metadata = null; } export function unloadPDFPage(pageNumber: number): void { pdfState.pages.delete(pageNumber); } ``` --- ### 6.2 Text Layer Renderer (Procedural) **File:** `web/src/reader/pdf/text-layer-renderer.ts` ```typescript // Text layer rendering for PDF text selection and highlighting // Procedural style: Functions, not classes // ============================================================ // Render Functions // ============================================================ export function renderTextLayer( container: HTMLElement, viewport: any, textContent: any, config: TextLayerConfig ): void { // Clear container container.innerHTML = ''; // Apply styles applyTextLayerStyles(container, config); // Render text items const { items } = textContent; items.forEach((item: any, index: number) => { if (typeof item === 'string') return; const textDiv = createTextDiv(item, viewport, index); container.appendChild(textDiv); }); } function createTextDiv(item: any, viewport: any, index: number): HTMLElement { const div = document.createElement('div'); div.className = 'pdf-text-layer-text'; div.textContent = item.str; div.dataset.index = index.toString(); // Position the text div const tx = pdfjsLib.Util.transform( viewport.transform, item.transform ); const fontSize = Math.sqrt((tx[0] * tx[0]) + (tx[1] * tx[1])); div.style.left = `${tx[4]}px`; div.style.top = `${tx[5] - fontSize}px`; div.style.fontSize = `${fontSize}px`; div.style.fontFamily = item.fontName || 'sans-serif'; // Handle text direction if (item.dir === 'ttb') { div.style.writingMode = 'vertical-rl'; } return div; } interface TextLayerConfig { theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; } function applyTextLayerStyles(container: HTMLElement, config: TextLayerConfig): void { const style = document.createElement('style'); style.textContent = getTextLayerCSS(config.theme); container.appendChild(style); } function getTextLayerCSS(theme: string): string { const colors = getThemeColors(theme); return ` .pdf-text-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; opacity: 1; line-height: 1; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; user-select: none; } .pdf-text-layer-text { position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; color: transparent; pointer-events: auto; } .pdf-text-layer-text::selection { background: ${colors.highlight}; color: transparent; } .pdf-text-layer-text::-moz-selection { background: ${colors.highlight}; color: transparent; } .pdf-highlight-overlay { position: absolute; background-color: ${colors.highlight}; mix-blend-mode: multiply; pointer-events: none; } `; } function getThemeColors(theme: string): { highlight: string } { const themes: Record = { 'light': { highlight: 'rgba(255, 255, 0, 0.3)' }, 'sepia': { highlight: 'rgba(255, 200, 0, 0.4)' }, 'dark': { highlight: 'rgba(255, 255, 0, 0.3)' }, 'night': { highlight: 'rgba(100, 150, 255, 0.3)' }, 'high-contrast': { highlight: 'rgba(255, 255, 0, 0.5)' } }; return themes[theme] || themes['dark']; } // ============================================================ // Selection Functions // ============================================================ export function getPDFTextSelection(): { text: string; range: Range } | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const text = range.toString(); if (!text) return null; return { text, range }; } export function getPDFSelectionRects(): DOMRect[] { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return []; const rects: DOMRect[] = []; const range = selection.getRangeAt(0); for (const rect of range.getClientRects()) { rects.push(rect); } return rects; } ``` --- ### 6.3 Annotation Layer (Procedural) **File:** `web/src/reader/pdf/annotation-layer.ts` ```typescript // Annotation layer for rendering highlights and notes on PDFs // Procedural style: Functions, not classes interface PDFHighlight { id: string; pageNumber: number; rects: DOMRect[]; text: string; color: string; noteId?: string; } const highlights = new Map(); export function renderPDFHighlights( container: HTMLElement, highlightList: PDFHighlight[] ): void { // Clear existing highlights clearPDFHighlights(container); for (const highlight of highlightList) { renderSinglePDFHighlight(container, highlight); } } function renderSinglePDFHighlight(container: HTMLElement, highlight: PDFHighlight): void { const overlay = document.createElement('div'); overlay.className = 'pdf-highlight-annotation'; overlay.dataset.highlightId = highlight.id; overlay.style.backgroundColor = parseColor(highlight.color); // Position highlight rectangles for (const rect of highlight.rects) { const rectDiv = document.createElement('div'); rectDiv.className = 'pdf-highlight-rect'; rectDiv.style.left = `${rect.left}px`; rectDiv.style.top = `${rect.top}px`; rectDiv.style.width = `${rect.width}px`; rectDiv.style.height = `${rect.height}px`; overlay.appendChild(rectDiv); } // Add click handler for note popup if (highlight.noteId) { overlay.style.cursor = 'pointer'; overlay.addEventListener('click', () => { showNotePopup(highlight); }); } // Add hover effect overlay.addEventListener('mouseenter', () => { overlay.style.opacity = '0.8'; }); overlay.addEventListener('mouseleave', () => { overlay.style.opacity = '0.5'; }); container.appendChild(overlay); highlights.set(highlight.id, overlay); } function parseColor(color: string): string { if (color.startsWith('#')) { const hex = color.slice(1); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, 0.4)`; } return color; } function showNotePopup(highlight: PDFHighlight): void { console.log('Show note for highlight:', highlight.id); } export function clearPDFHighlights(container: HTMLElement): void { highlights.forEach(element => element.remove()); highlights.clear(); } export function removePDFHighlight(highlightId: string): void { const element = highlights.get(highlightId); if (element) { element.remove(); highlights.delete(highlightId); } } ``` --- ### 6.4 PDF Navigation (Procedural) **File:** `web/src/reader/pdf/pdf-navigation.ts` ```typescript // PDF navigation: page turning, zoom, fit modes // Procedural style: Functions, not classes type PageFitMode = 'fit-width' | 'fit-page' | 'fit-height' | 'none'; interface PDFNavigationState { currentPage: number; totalPages: number; currentScale: number; fitMode: PageFitMode; scrollContainer: HTMLElement | null; } let navState: PDFNavigationState = { currentPage: 1, totalPages: 0, currentScale: 1.0, fitMode: 'fit-width', scrollContainer: null, }; // ============================================================ // Initialization // ============================================================ export function initializePDFNavigation( container: HTMLElement, onPageChange: (pageNumber: number) => void, onZoomChange: (scale: number) => void ): void { navState.scrollContainer = container.querySelector('.pdf-scroll-container') || container; setupPDFKeyboardNav(onPageChange); setupPDFScrollTracking(onPageChange); } export function setPDFTotalPages(totalPages: number): void { navState.totalPages = totalPages; } // ============================================================ // Page Navigation // ============================================================ export function goToPDFPage(pageNumber: number): void { if (pageNumber < 1 || pageNumber > navState.totalPages) return; navState.currentPage = pageNumber; const callback = (window as any).pdfOnPageChange; if (callback) callback(pageNumber); scrollToPDFPage(pageNumber); } export function nextPDFPage(): void { if (navState.currentPage < navState.totalPages) { goToPDFPage(navState.currentPage + 1); } } export function previousPDFPage(): void { if (navState.currentPage > 1) { goToPDFPage(navState.currentPage - 1); } } function scrollToPDFPage(pageNumber: number): void { if (!navState.scrollContainer) return; const pageElement = navState.scrollContainer.querySelector(`[data-page-number="${pageNumber}"]`); if (pageElement) { pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } // ============================================================ // Zoom Controls // ============================================================ export function setPDFZoom(scale: number): void { navState.currentScale = scale; navState.fitMode = 'none'; const callback = (window as any).pdfOnZoomChange; if (callback) callback(scale); updatePDFZoom(); } export function setPDFFitMode(mode: PageFitMode): void { navState.fitMode = mode; updatePDFZoom(); } export function zoomPDFIn(): void { setPDFZoom(navState.currentScale * 1.2); } export function zoomPDFOut(): void { setPDFZoom(navState.currentScale / 1.2); } function updatePDFZoom(): void { if (!navState.scrollContainer) return; const pages = navState.scrollContainer.querySelectorAll('.pdf-page-container'); pages.forEach((page: Element) => { (page as HTMLElement).style.transform = `scale(${navState.currentScale})`; (page as HTMLElement).style.transformOrigin = 'top center'; }); } // ============================================================ // Keyboard Navigation // ============================================================ function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void { document.addEventListener('keydown', handlePDFKeyDown); } function handlePDFKeyDown(e: KeyboardEvent): void { switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); nextPDFPage(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); previousPDFPage(); break; case 'Home': e.preventDefault(); goToPDFPage(1); break; case 'End': e.preventDefault(); goToPDFPage(navState.totalPages); break; } } // ============================================================ // Scroll Tracking // ============================================================ function setupPDFScrollTracking(onPageChange: (pageNumber: number) => void): void { if (!navState.scrollContainer) return; let scrollTimeout: NodeJS.Timeout; navState.scrollContainer.addEventListener('scroll', () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { updateCurrentPageFromScroll(onPageChange); }, 100); }); } function updateCurrentPageFromScroll(onPageChange: (pageNumber: number) => void): void { if (!navState.scrollContainer) return; const scrollTop = navState.scrollContainer.scrollTop; const containerHeight = navState.scrollContainer.clientHeight; const pages = navState.scrollContainer.querySelectorAll('[data-page-number]'); let maxVisibility = 0; let mostVisiblePage = navState.currentPage; pages.forEach((page) => { const element = page as HTMLElement; const pageTop = element.offsetTop; const pageBottom = pageTop + element.offsetHeight; const visibleTop = Math.max(scrollTop, pageTop); const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom); const visibleHeight = Math.max(0, visibleBottom - visibleTop); if (visibleHeight > maxVisibility) { maxVisibility = visibleHeight; mostVisiblePage = parseInt(element.dataset.pageNumber || '1'); } }); if (mostVisiblePage !== navState.currentPage) { navState.currentPage = mostVisiblePage; onPageChange(mostVisiblePage); } } // ============================================================ // Getters // ============================================================ export function getCurrentPDFPage(): number { return navState.currentPage; } export function getTotalPDFPages(): number { return navState.totalPages; } export function getPDFScale(): number { return navState.currentScale; } ``` ### 6.5 PDF Search **File:** `web/src/reader/pdf/pdf-search.ts` ```typescript // Full-text search within PDF documents import { PDFDocumentProxy } from 'pdfjs-dist'; interface SearchResult { pageNumber: number; text: string; index: number; context: string; } // Full-text search within PDF documents // Procedural implementation (no OOP) interface SearchResult { pageNumber: number; text: string; index: number; context: string; } interface PDFSearchState { doc: PDFDocumentProxy | null; searchResults: SearchResult[]; currentResultIndex: number; } async function initializePDFSearch(doc: PDFDocumentProxy): Promise { return { doc, searchResults: [], currentResultIndex: 0 }; } async function searchPDF(state: PDFSearchState, query: string): Promise { if (!state.doc) return state; const searchResults: SearchResult[] = []; const lowerQuery = query.toLowerCase(); for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { const page = await state.doc.getPage(pageNum); const textContent = await page.getTextContent(); let fullText = ''; const textItems = textContent.items.map(item => { if (typeof item === 'string') return ''; fullText += item.str; return item.str; }); const pageText = textItems.join(' '); const matches = findSearchMatches(pageText, lowerQuery, pageNum); searchResults.push(...matches); } return { ...state, searchResults }; } function findSearchMatches( text: string, query: string, pageNumber: number ): SearchResult[] { const matches: SearchResult[] = []; const lowerText = text.toLowerCase(); let index = 0; while ((index = lowerText.indexOf(query, index)) !== -1) { const start = Math.max(0, index - 50); const end = Math.min(text.length, index + query.length + 50); const context = text.slice(start, end); matches.push({ pageNumber, text: text.slice(index, index + query.length), index, context }); index += query.length; } return matches; } function goToNextSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { if (state.searchResults.length === 0) { return { ...state, result: null }; } const newIndex = (state.currentResultIndex + 1) % state.searchResults.length; return { ...state, currentResultIndex: newIndex, result: state.searchResults[newIndex] }; } function goToPreviousSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { if (state.searchResults.length === 0) { return { ...state, result: null }; } const newIndex = (state.currentResultIndex - 1 + state.searchResults.length) % state.searchResults.length; return { ...state, currentResultIndex: newIndex, result: state.searchResults[newIndex] }; } function getSearchResultCount(state: PDFSearchState): number { return state.searchResults.length; } function clearSearchResults(state: PDFSearchState): PDFSearchState { return { ...state, searchResults: [], currentResultIndex: 0 }; } ``` ### 6.6 Page Cache (Pre-rendering) **File:** `web/src/reader/pdf/page-cache.ts` ```typescript // 5-page ahead cache for PDF pages // Pre-renders canvas and text layer for nearby pages import { PDFPageProxy, PageViewport } from 'pdfjs-dist'; interface CachedPage { pageNumber: number; canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport; timestamp: number; } // 5-page ahead cache for PDF pages // Procedural implementation (no OOP) interface CachedPage { pageNumber: number; canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport; timestamp: number; } interface PDFPageCacheState { cache: Map; maxCacheSize: number; renderCallbacks: Map void>>; } function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState { return { cache: new Map(), maxCacheSize, renderCallbacks: new Map() }; } async function getCachedPage( state: PDFPageCacheState, pageNumber: number, renderFn: (pageNumber: number) => Promise<{ canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport }> ): Promise { const cached = state.cache.get(pageNumber); if (cached) { cached.timestamp = Date.now(); return { ...state, page: cached }; } const { canvas, textLayer, viewport } = await renderFn(pageNumber); const cachedPage: CachedPage = { pageNumber, canvas, textLayer, viewport, timestamp: Date.now() }; const newCache = new Map(state.cache); newCache.set(pageNumber, cachedPage); const callbacks = state.renderCallbacks.get(pageNumber); if (callbacks) { callbacks.forEach(cb => cb()); const newCallbacks = new Map(state.renderCallbacks); newCallbacks.delete(pageNumber); return { ...state, cache: newCache, renderCallbacks: newCallbacks, page: cachedPage }; } return { ...state, cache: newCache, page: cachedPage }; } function preloadPages( state: PDFPageCacheState, currentPage: number, totalPages: number ): PDFPageCacheState { for (let i = 1; i <= state.maxCacheSize; i++) { const pageNumber = currentPage + i; if (pageNumber <= totalPages && !state.cache.has(pageNumber)) { triggerPreload(pageNumber); } } return state; } function triggerPreload(pageNumber: number): void { console.log('Preloading page:', pageNumber); } function invalidatePage( state: PDFPageCacheState, pageNumber: number ): PDFPageCacheState { const cached = state.cache.get(pageNumber); if (cached) { cached.canvas.remove(); cached.textLayer.remove(); const newCache = new Map(state.cache); newCache.delete(pageNumber); return { ...state, cache: newCache }; } return state; } function clearPageCache(state: PDFPageCacheState): PDFPageCacheState { state.cache.forEach(page => { page.canvas.remove(); page.textLayer.remove(); }); return { ...state, cache: new Map() }; } function onPageRendered( state: PDFPageCacheState, pageNumber: number, callback: () => void ): PDFPageCacheState { const newCallbacks = new Map(state.renderCallbacks); if (!newCallbacks.has(pageNumber)) { newCallbacks.set(pageNumber, []); } newCallbacks.get(pageNumber)!.push(callback); return { ...state, renderCallbacks: newCallbacks }; } ``` ### 6.7 PDF Text Selection (Uses Backend API) **File:** `web/src/reader/pdf/pdf-text-selection.ts` ```typescript // PDF text selection - Uses backend API for highlight creation // Backend handles all position calculations for PDFs // Procedural style: Functions, not classes interface PDFTextSelection { pageNumber: number; text: string; rects: DOMRect[]; } // ============================================================ // Get PDF Text Selection // ============================================================ export function getPDFTextSelection(): PDFTextSelection | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const text = range.toString(); if (!text) return null; // Get page number from selection const pageElement = range.commonAncestorContainer.closest?.('[data-page-number]'); const pageNumber = pageElement?.dataset.pageNumber ? parseInt(pageElement.dataset.pageNumber) : getCurrentPDFPage(); // Get bounding rectangles const rects: DOMRect[] = []; for (const rect of range.getClientRects()) { rects.push(rect); } return { pageNumber, text, rects }; } // ============================================================ // Create PDF Highlight (Backend Calculates Position) // ============================================================ export async function createPDFHighlight( mediaItemId: string, selection: PDFTextSelection, color: string ): Promise { const selectionData = { selection_text: selection.text, page_number: selection.pageNumber, rects: selection.rects.map(rect => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })), color }; // Send to backend - backend calculates all position formats const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(selectionData) }); if (!response.ok) { throw new Error(`Failed to create highlight: ${response.statusText}`); } return await response.json(); } // ============================================================ // Load and Render PDF Highlights (Backend Provides Positions) // ============================================================ export async function loadAndRenderPDFHighlights( mediaItemId: string, container: HTMLElement ): Promise { const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); if (!response.ok) return []; const highlights: Highlight[] = await response.json(); for (const highlight of highlights) { renderPDFHighlight(container, highlight); } } function renderPDFHighlight(container: HTMLElement, highlight: Highlight): void { // Backend provides position data for PDF highlights // Check which position format is available if (highlight.start_position && highlight.start_position.startsWith('pdf:page:')) { // Backend calculated page-based position renderPDFHighlightByPosition(container, highlight); } else if (highlight.percentage_start !== null) { // Backend calculated percentage position renderPDFHighlightByPercentage(container, highlight); } } function renderPDFHighlightByPosition(container: HTMLElement, highlight: Highlight): void { // Parse position string: "pdf:page:45:offset:123" const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/); if (!match) return; const pageNumber = parseInt(match[1], 10); const offset = parseInt(match[2], 10); // Find the page element const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`); if (!pageElement) return; // Get text content at offset const textContent = pageElement.querySelector('.pdf-text-layer')?.textContent; if (!textContent) return; // Find the text at this offset const textBefore = textContent.substring(0, offset); const startChar = textBefore.length; const endChar = startChar + (highlight.selection_text?.length || 10); if (startChar < textContent.length && endChar <= textContent.length) { applyHighlightToTextContent( pageElement as HTMLElement, startChar, endChar, highlight.color ); } } function renderPDFHighlightByPercentage(container: HTMLElement, highlight: Highlight): void { // Backend provides percentage - estimate position const percentage = highlight.percentage_start || 0; // Find spine item closest to this percentage const totalPages = container.querySelectorAll('[data-page-number]').length; const targetPage = Math.ceil(percentage * totalPages); const pageElement = container.querySelector(`[data-page-number="${targetPage}"]`); if (pageElement) { // Highlight entire page (coarse-grained) applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color); } } function applyHighlightToTextContent( element: HTMLElement, startChar: number, endChar: number, color: string ): void { const text = element.textContent || ''; const before = text.substring(0, startChar); const selection = text.substring(startChar, endChar); const after = text.substring(endChar); element.textContent = before + selection + after; // Use a mark to wrap the selected text element.innerHTML = `${before}${selection}${after}`; } ``` --- ### 6.8 PDF Outline/TOC Navigation **File:** `web/src/reader/pdf/pdf-outline.ts` ```typescript // PDF outline/TOC navigation // Procedural implementation (no OOP) interface PDFOutlineNode { id: string; title: string; destination: number | null; pageNumber?: number; children: PDFOutlineNode[]; expanded: boolean; } interface PDFOutlineState { doc: PDFDocumentProxy | null; outline: PDFOutlineNode[]; flatMap: Map; } async function initializePDFOutline(doc: PDFDocumentProxy): Promise { const state: PDFOutlineState = { doc, outline: [], flatMap: new Map() }; return await loadPDFOutline(state); } async function loadPDFOutline(state: PDFOutlineState): Promise { if (!state.doc) return state; const pdfOutline = await state.doc.getOutline(); if (!pdfOutline || pdfOutline.length === 0) { return { ...state, outline: [] }; } const outline = await parseOutlineNodes(state, pdfOutline); return { ...state, outline }; } async function parseOutlineNodes( state: PDFOutlineState, nodes: OutlineTreeNode[] ): Promise { const result: PDFOutlineNode[] = []; for (const node of nodes) { const outlineNode: PDFOutlineNode = { id: generateOutlineId(), title: node.title, destination: null, children: [], expanded: false }; if (node.dest) { const pageNumber = await resolvePDFDestination(state, node.dest); outlineNode.destination = pageNumber; outlineNode.pageNumber = pageNumber; state.flatMap.set(node.title, pageNumber); } if (node.items && node.items.length > 0) { outlineNode.children = await parseOutlineNodes(state, node.items); } result.push(outlineNode); } return result; } async function resolvePDFDestination( state: PDFOutlineState, dest: string | any[] ): Promise { if (!state.doc) return 1; try { let explicitDest: any[]; if (typeof dest === 'string') { const destObj = await state.doc.getDestination(dest); if (!destObj) return 1; explicitDest = destObj; } else { explicitDest = dest; } const ref = explicitDest[0]; if (typeof ref === 'object' && ref !== null) { const pageIndex = await state.doc.getPageIndex(ref); return pageIndex + 1; } else if (typeof ref === 'number') { return ref + 1; } return 1; } catch (error) { console.error('Failed to resolve destination:', dest, error); return 1; } } function generateOutlineId(): string { return `outline-${Math.random().toString(36).substr(2, 9)}`; } function getOutline(state: PDFOutlineState): PDFOutlineNode[] { return state.outline; } function getOutlineFlatMap(state: PDFOutlineState): Map { return state.flatMap; } function getCurrentChapter( state: PDFOutlineState, pageNumber: number ): PDFOutlineNode | null { return findChapterForPage(state.outline, pageNumber); } function findChapterForPage( nodes: PDFOutlineNode[], pageNumber: number ): PDFOutlineNode | null { for (const node of nodes) { if (node.pageNumber && node.pageNumber <= pageNumber) { if (node.children.length > 0) { const childMatch = findChapterForPage(node.children, pageNumber); if (childMatch) return childMatch; } return node; } if (node.children.length > 0) { const childMatch = findChapterForPage(node.children, pageNumber); if (childMatch) return childMatch; } } return null; } function toggleOutlineNode( state: PDFOutlineState, nodeId: string ): PDFOutlineState { const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => { return nodes.map(node => { if (node.id === nodeId) { return { ...node, expanded: !node.expanded }; } if (node.children.length > 0) { return { ...node, children: updateNode(node.children) }; } return node; }); }; return { ...state, outline: updateNode(state.outline) }; } function findOutlineNode( nodes: PDFOutlineNode[], id: string ): PDFOutlineNode | null { for (const node of nodes) { if (node.id === id) return node; if (node.children.length > 0) { const found = findOutlineNode(node.children, id); if (found) return found; } } return null; } ``` ### 6.9 PDF Bookmarks **File:** `web/src/reader/pdf/pdf-bookmarks.ts` ```typescript // Custom bookmarks for PDF pages (saved in database) // Procedural implementation (no OOP) interface PDFBookmark { id: string; mediaItemId: string; userId: string; pageNumber: number; title: string; createdAt: string; } interface PDFBookmarksState { mediaItemId: string; bookmarks: PDFBookmark[]; } function createPDFBookmarks(mediaItemId: string): PDFBookmarksState { return { mediaItemId, bookmarks: [] }; } async function loadPDFBookmarks(state: PDFBookmarksState): Promise { try { const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`); if (!response.ok) throw new Error('Failed to load bookmarks'); const data = await response.json(); return { ...state, bookmarks: data.bookmarks || [] }; } catch (error) { console.error('Failed to load bookmarks:', error); return { ...state, bookmarks: [] }; } } async function addPDFBookmark( state: PDFBookmarksState, pageNumber: number, title?: string ): Promise { const bookmark: PDFBookmark = { id: crypto.randomUUID(), mediaItemId: state.mediaItemId, userId: '', pageNumber, title: title || `Page ${pageNumber}`, createdAt: new Date().toISOString() }; try { const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_number: pageNumber, title: bookmark.title, position: `pdf:page:${pageNumber}` }) }); if (!response.ok) throw new Error('Failed to create bookmark'); const created = await response.json(); return { ...state, bookmarks: [...state.bookmarks, created], bookmark: created }; } catch (error) { console.error('Failed to add bookmark:', error); throw error; } } async function removePDFBookmark( state: PDFBookmarksState, bookmarkId: string ): Promise { try { const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to remove bookmark'); return { ...state, bookmarks: state.bookmarks.filter(b => b.id !== bookmarkId) }; } catch (error) { console.error('Failed to remove bookmark:', error); throw error; } } function getPDFBookmarks(state: PDFBookmarksState): PDFBookmark[] { return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber); } function hasPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): boolean { return state.bookmarks.some(b => b.pageNumber === pageNumber); } function getPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): PDFBookmark | null { return state.bookmarks.find(b => b.pageNumber === pageNumber) || null; } ``` ### 6.10 PDF Clipboard **File:** `web/src/reader/pdf/pdf-clipboard.ts` ```typescript // Copy selected text to clipboard (plain text, preserve line breaks) // Critical for technical textbooks with code examples // Procedural implementation (no OOP) function setupPDFClipboard(container: HTMLElement): void { container.addEventListener('copy', (e) => { handlePDFCopy(e); }); } function handlePDFCopy(event: ClipboardEvent): void { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const selectedText = selection.toString(); if (!selectedText) return; const plainText = formatPDFPlainText(selectedText); event.clipboardData?.setData('text/plain', plainText); event.preventDefault(); showPDFCopyFeedback(); } function formatPDFPlainText(text: string): string { let formatted = text; formatted = formatted.replace(/[ \t]+/g, ' '); formatted = formatted.split('\n').map(line => line.trim()).join('\n'); formatted = formatted.replace(/\n{3,}/g, '\n\n'); return formatted; } async function copyPDFText(text: string): Promise { const formatted = formatPDFPlainText(text); try { await navigator.clipboard.writeText(formatted); showPDFCopyFeedback(); return true; } catch (error) { console.error('Failed to copy text:', error); const textarea = document.createElement('textarea'); textarea.value = formatted; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { const success = document.execCommand('copy'); if (success) { showPDFCopyFeedback(); } return success; } catch (fallbackError) { console.error('Fallback copy failed:', fallbackError); return false; } finally { document.body.removeChild(textarea); } } } function showPDFCopyFeedback(): void { const toast = document.createElement('div'); toast.className = 'pdf-copy-toast'; toast.textContent = 'Copied to clipboard'; toast.style.cssText = ` position: fixed; bottom: 20px; right: 20px; background: var(--accent); color: white; padding: 8px 16px; border-radius: 4px; font-size: 14px; z-index: 10000; animation: fadeIn 0.2s ease-out; `; document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'fadeOut 0.2s ease-out'; setTimeout(() => toast.remove(), 200); }, 1500); } ``` ### 6.11 PDF Link Handler **File:** `web/src/reader/pdf/pdf-links.ts` ```typescript // Handle internal PDF links (cross-references, citations, TOC links) // External links open in new tab // Procedural implementation (no OOP) interface PDFLink { url: string; pageNumber?: number; bounds: { x: number; y: number; width: number; height: number }; } interface PDFLinkHandlerState { doc: PDFDocumentProxy | null; container: HTMLElement; onPageNavigate: (pageNumber: number) => void; } async function initializePDFLinkHandler( container: HTMLElement, onPageNavigate: (pageNumber: number) => void, doc: PDFDocumentProxy ): Promise { const state: PDFLinkHandlerState = { doc, container, onPageNavigate }; await setupPDFLinks(state); return state; } async function setupPDFLinks(state: PDFLinkHandlerState): Promise { if (!state.doc) return; for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { const page = await state.doc.getPage(pageNum); const annotations = await page.getAnnotations(); for (const annotation of annotations) { if (annotation.subtype === 'Link') { createPDFLinkElement(state, annotation, pageNum); } } } } function createPDFLinkElement( state: PDFLinkHandlerState, annotation: any, pageNumber: number ): void { const pageElement = state.container.querySelector(`[data-page-number="${pageNumber}"]`); if (!pageElement) return; const link = document.createElement('a'); link.className = 'pdf-internal-link'; link.href = 'javascript:void(0)'; if (annotation.rect) { const rect = annotation.rect; link.style.position = 'absolute'; link.style.left = `${rect[0]}px`; link.style.top = `${rect[1]}px`; link.style.width = `${rect[2] - rect[0]}px`; link.style.height = `${rect[3] - rect[1]}px`; link.style.cursor = 'pointer'; } link.addEventListener('click', (e) => { e.preventDefault(); handlePDFLinkClick(state, annotation); }); pageElement.appendChild(link); } async function handlePDFLinkClick( state: PDFLinkHandlerState, annotation: any ): Promise { if (!state.doc) return; if (annotation.url) { if (annotation.url.startsWith('http://') || annotation.url.startsWith('https://')) { window.open(annotation.url, '_blank', 'noopener,noreferrer'); } else { console.warn('Unhandled URL:', annotation.url); } } else if (annotation.dest) { const pageNumber = await resolvePDFLinkDestination(state, annotation.dest); state.onPageNavigate(pageNumber); } } async function resolvePDFLinkDestination( state: PDFLinkHandlerState, dest: string | any[] ): Promise { if (!state.doc) return 1; try { let explicitDest: any[]; if (typeof dest === 'string') { const destObj = await state.doc.getDestination(dest); if (!destObj) return 1; explicitDest = destObj; } else { explicitDest = dest; } const ref = explicitDest[0]; if (typeof ref === 'object' && ref !== null) { const pageIndex = await state.doc.getPageIndex(ref); return pageIndex + 1; } else if (typeof ref === 'number') { return ref + 1; } return 1; } catch (error) { console.error('Failed to resolve link destination:', error); return 1; } } ``` ### 6.12 PDF Dual Page Spread View **File:** `web/src/reader/pdf/pdf-dual-page.ts` ```typescript // Dual page spread view for PDFs // Procedural implementation (no OOP) type DualPageMode = 'single' | 'dual'; interface PDFDualPageViewState { currentMode: DualPageMode; minViewportWidth: number; } function createPDFDualPageView( container: HTMLElement, onModeChange: (mode: DualPageMode) => void ): PDFDualPageViewState { const state: PDFDualPageViewState = { currentMode: 'single', minViewportWidth: 1200 }; setupResponsiveDualPageToggle(container, state, onModeChange); return state; } function setupResponsiveDualPageToggle( container: HTMLElement, state: PDFDualPageViewState, onModeChange: (mode: DualPageMode) => void ): void { const resizeObserver = new ResizeObserver(() => { handleDualPageResize(container, state, onModeChange); }); resizeObserver.observe(container); } function handleDualPageResize( container: HTMLElement, state: PDFDualPageViewState, onModeChange: (mode: DualPageMode) => void ): PDFDualPageViewState { const viewportWidth = window.innerWidth; if (viewportWidth >= state.minViewportWidth && state.currentMode === 'single') { if (!hasManualDualPageOverride()) { return setDualPageMode(container, state, 'dual', false, onModeChange); } } else if (viewportWidth < state.minViewportWidth && state.currentMode === 'dual') { return setDualPageMode(container, state, 'single', false, onModeChange); } return state; } function setDualPageMode( container: HTMLElement, state: PDFDualPageViewState, mode: DualPageMode, manual: boolean, onModeChange: (mode: DualPageMode) => void ): PDFDualPageViewState { if (state.currentMode === mode) return state; container.classList.remove('pdf-single-page', 'pdf-dual-page'); container.classList.add(mode === 'dual' ? 'pdf-dual-page' : 'pdf-single-page'); if (manual) { setManualDualPageOverride(mode); } onModeChange(mode); return { ...state, currentMode: mode }; } function toggleDualPageMode( container: HTMLElement, state: PDFDualPageViewState, onModeChange: (mode: DualPageMode) => void ): PDFDualPageViewState { const newMode = state.currentMode === 'single' ? 'dual' : 'single'; return setDualPageMode(container, state, newMode, true, onModeChange); } function getDualPagePagePair( state: PDFDualPageViewState, currentPage: number, totalPages: number ): { left?: number; right: number } { if (state.currentMode === 'single') { return { right: currentPage }; } if (currentPage % 2 === 1) { return { left: currentPage > 1 ? currentPage - 1 : undefined, right: currentPage }; } else { return { left: currentPage, right: currentPage < totalPages ? currentPage + 1 : currentPage }; } } function hasManualDualPageOverride(): boolean { return localStorage.getItem('pdf-dual-page-manual') === 'true'; } function setManualDualPageOverride(mode: DualPageMode): void { localStorage.setItem('pdf-dual-page-manual', 'true'); localStorage.setItem('pdf-dual-page-mode', mode); } function getDualPageStyles(): string { return ` .pdf-dual-page .pdf-page-container { display: inline-block; vertical-align: top; width: 50%; } .pdf-dual-page .pdf-scroll-container { display: flex; flex-wrap: wrap; justify-content: center; } .pdf-single-page .pdf-page-container { display: block; width: 100%; } `; } ``` ### 6.13 PDF Mini-Map Navigation **File:** `web/src/reader/pdf/pdf-minimap.ts` ```typescript // Mini-map navigation for PDF pages // Procedural implementation (no OOP) interface PDFMiniMapState { miniMap: HTMLElement; currentPage: number; totalPages: number; thumbnails: Map; onPageNavigate: (pageNumber: number) => void; } function createPDFMiniMap( container: HTMLElement, onPageNavigate: (pageNumber: number) => void ): PDFMiniMapState { const miniMap = createMiniMapElement(container); container.appendChild(miniMap); return { miniMap, currentPage: 1, totalPages: 0, thumbnails: new Map(), onPageNavigate }; } function createMiniMapElement(container: HTMLElement): HTMLElement { const miniMap = document.createElement('div'); miniMap.className = 'pdf-minimap'; miniMap.innerHTML = `
Pages
`; const style = document.createElement('style'); style.textContent = getMiniMapStyles(); miniMap.appendChild(style); return miniMap; } async function initializePDFMiniMap( state: PDFMiniMapState, totalPages: number, renderThumbnail: (page: number) => Promise ): Promise { const newState = { ...state, totalPages }; await generateMiniMapThumbnails(newState, renderThumbnail); setupMiniMapEventListeners(newState); return newState; } async function generateMiniMapThumbnails( state: PDFMiniMapState, renderThumbnail: (page: number) => Promise ): Promise { const container = state.miniMap.querySelector('.pdf-minimap-thumbnails') as HTMLElement; container.innerHTML = ''; for (let page = 1; page <= state.totalPages; page++) { try { const thumbnail = await renderThumbnail(page); thumbnail.className = 'pdf-minimap-thumbnail'; thumbnail.dataset.pageNumber = page.toString(); thumbnail.style.width = '80px'; thumbnail.style.height = 'auto'; thumbnail.style.cursor = 'pointer'; thumbnail.style.marginBottom = '4px'; container.appendChild(thumbnail); state.thumbnails.set(page, thumbnail); } catch (error) { console.error(`Failed to generate thumbnail for page ${page}:`, error); } } } function setupMiniMapEventListeners(state: PDFMiniMapState): void { const container = state.miniMap.querySelector('.pdf-minimap-thumbnails'); container?.addEventListener('click', (e) => { const thumbnail = (e.target as HTMLElement).closest('.pdf-minimap-thumbnail') as HTMLElement; if (thumbnail) { const pageNumber = parseInt(thumbnail.dataset.pageNumber || '1'); state.onPageNavigate(pageNumber); } }); } function updateMiniMapCurrentPage(state: PDFMiniMapState, pageNumber: number): PDFMiniMapState { const indicator = state.miniMap.querySelector('.pdf-minimap-indicator') as HTMLElement; const thumbnail = state.thumbnails.get(pageNumber); if (thumbnail && indicator) { const rect = thumbnail.getBoundingClientRect(); indicator.style.top = `${thumbnail.offsetTop}px`; indicator.style.height = `${rect.height}px`; } state.thumbnails.forEach((thumb, page) => { if (page === pageNumber) { thumb.style.outline = '2px solid var(--accent)'; thumb.style.opacity = '1'; } else { thumb.style.outline = 'none'; thumb.style.opacity = '0.7'; } }); return { ...state, currentPage: pageNumber }; } function showMiniMap(state: PDFMiniMapState): void { state.miniMap.style.display = 'block'; } function hideMiniMap(state: PDFMiniMapState): void { state.miniMap.style.display = 'none'; } function toggleMiniMap(state: PDFMiniMapState): void { const isVisible = state.miniMap.style.display !== 'none'; state.miniMap.style.display = isVisible ? 'none' : 'block'; } function getMiniMapStyles(): string { return ` .pdf-minimap { position: fixed; right: 20px; top: 50%; transform: translateY(-50%); width: 100px; max-height: 80vh; background: var(--bg-primary); border: 1px solid var(--text-secondary); border-radius: 8px; padding: 8px; overflow-y: auto; z-index: 1000; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .pdf-minimap-header { font-size: 12px; font-weight: bold; text-align: center; margin-bottom: 8px; color: var(--text-primary); } .pdf-minimap-thumbnails { display: flex; flex-direction: column; align-items: center; gap: 4px; } .pdf-minimap-thumbnail { transition: outline 0.2s, opacity 0.2s; border-radius: 2px; } .pdf-minimap-thumbnail:hover { opacity: 1 !important; outline: 1px solid var(--text-secondary) !important; } .pdf-minimap-indicator { position: absolute; left: 0; right: 0; border-left: 3px solid var(--accent); pointer-events: none; transition: top 0.3s ease-out; } `; } ``` ### 6.14 PDF Rotated Page Support **File:** `web/src/reader/pdf/pdf-rotation.ts` ```typescript // Handle rotated/landscape pages in PDFs // Procedural implementation (no OOP) interface PDFRotationState { rotations: Map; } function createPDFRotation(): PDFRotationState { return { rotations: new Map() }; } async function loadPDFPageRotations( state: PDFRotationState, doc: any ): Promise { const rotations = new Map(); for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale: 1 }); const rotation = viewport.rotation; if (rotation !== 0) { rotations.set(pageNum, rotation); } } return { ...state, rotations }; } function getPDFPageRotation(state: PDFRotationState, pageNumber: number): number { return state.rotations.get(pageNumber) || 0; } function hasPDFPageRotation(state: PDFRotationState, pageNumber: number): boolean { return state.rotations.has(pageNumber); } function applyPDFRotation( state: PDFRotationState, canvas: HTMLCanvasElement, pageNumber: number ): void { const rotation = getPDFPageRotation(state, pageNumber); if (rotation === 0) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((rotation * Math.PI) / 180); ctx.translate(-canvas.width / 2, -canvas.height / 2); ctx.restore(); } function getPDFAdjustedViewport( state: PDFRotationState, pageNumber: number, viewport: any ): any { const rotation = getPDFPageRotation(state, pageNumber); if (rotation === 0 || rotation === 180) { return viewport; } return { ...viewport, width: viewport.height, height: viewport.width }; } ``` ### 6.15 PDF Variable Page Sizes **File:** `web/src/reader/pdf/pdf-page-sizes.ts` ```typescript // Handle PDFs with variable page sizes // Procedural implementation (no OOP) interface PageInfo { pageNumber: number; width: number; height: number; rotation: number; } interface PDFPageSizesState { pageSizes: Map; defaultSize: { width: number; height: number }; } function createPDFPageSizes(): PDFPageSizesState { return { pageSizes: new Map(), defaultSize: { width: 595, height: 842 } }; } async function loadPDFPageSizes( state: PDFPageSizesState, doc: any ): Promise { const pageSizes = new Map(); for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale: 1 }); const pageInfo: PageInfo = { pageNumber: pageNum, width: viewport.width, height: viewport.height, rotation: viewport.rotation }; pageSizes.set(pageNum, pageInfo); } return { ...state, pageSizes }; } function getPDFPageSize( state: PDFPageSizesState, pageNumber: number ): PageInfo | null { return state.pageSizes.get(pageNumber) || null; } function isPDFPageLandscape( state: PDFPageSizesState, pageNumber: number ): boolean { const size = getPDFPageSize(state, pageNumber); if (!size) return false; const effectiveWidth = size.rotation === 90 || size.rotation === 270 ? size.height : size.width; const effectiveHeight = size.rotation === 90 || size.rotation === 270 ? size.width : size.height; return effectiveWidth > effectiveHeight; } function getPDFCommonSize(state: PDFPageSizesState): { width: number; height: number } { if (state.pageSizes.size === 0) { return state.defaultSize; } const sizeGroups: Map = new Map(); state.pageSizes.forEach((size) => { const key = getPageSizeKey(size.width, size.height); const existing = sizeGroups.get(key); if (existing) { existing.count++; } else { sizeGroups.set(key, { width: size.width, height: size.height, count: 1 }); } }); let mostCommon = state.defaultSize; let maxCount = 0; sizeGroups.forEach((size) => { if (size.count > maxCount) { maxCount = size.count; mostCommon = { width: size.width, height: size.height }; } }); return mostCommon; } function getPageSizeKey(width: number, height: number): string { const w = Math.round(width / 10) * 10; const h = Math.round(height / 10) * 10; return `${w}x${h}`; } ``` --- ## 7. Panel Detection Implementation ### 7.1 Grid-Based Detection (Primary) **File:** `web/src/reader/comic/panel-detector.ts` ```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; } ``` ### 7.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 })); } ``` ### 7.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); } ``` --- ## 8. Lazy Loading & Caching ### 8.1 Page Cache (5-Page Ahead) **File:** `web/src/reader/comic/page-cache.ts` ```typescript // Lazy-loading page cache with 5-page ahead prefetch // Lazy-loading page cache with 5-page ahead prefetch // Procedural implementation (no OOP) interface PageCacheState { cache: Map; loading: Set; maxAhead: number; mediaItemId: string; } function createPageCache(mediaItemId: string): PageCacheState { return { cache: new Map(), loading: new Set(), maxAhead: 5, mediaItemId }; } async function getCachedPage( state: PageCacheState, pageNumber: number ): Promise { if (state.cache.has(pageNumber)) { return { ...state, page: state.cache.get(pageNumber)! }; } if (state.loading.has(pageNumber)) { return new Promise((resolve) => { const checkInterval = setInterval(() => { if (state.cache.has(pageNumber)) { clearInterval(checkInterval); resolve({ ...state, page: state.cache.get(pageNumber)! }); } }, 100); }) as Promise; } const newLoading = new Set(state.loading); newLoading.add(pageNumber); const img = await loadComicPage(state, pageNumber); const newCache = new Map(state.cache); newCache.set(pageNumber, img); newLoading.delete(pageNumber); const newState = { ...state, cache: newCache, loading: newLoading }; prefetchPages(newState, pageNumber + 1); cleanupPageCache(newState, pageNumber); return { ...newState, page: img }; } async function loadComicPage( state: PageCacheState, pageNumber: number ): Promise { const token = localStorage.getItem('token'); const response = await fetch( `/api/readers/${state.mediaItemId}/pages/${pageNumber}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!response.ok) { throw new Error(`Failed to load page ${pageNumber}`); } const blob = await response.blob(); const img = new Image(); img.src = URL.createObjectURL(blob); await new Promise((resolve) => { img.onload = resolve; }); return img; } function prefetchPages(state: PageCacheState, startPage: number): void { for (let i = startPage; i < startPage + state.maxAhead; i++) { if (!state.cache.has(i) && !state.loading.has(i)) { loadComicPage(state, i).then((img) => { state.cache.set(i, img); }); } } } function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState { const keepPages = 10; const newCache = new Map(state.cache); for (const [page] of state.cache) { if (page < currentPage - keepPages) { newCache.delete(page); } } return { ...state, cache: newCache }; } ``` --- ## 9. Offline Support (PWA) ### 9.1 Service Worker **File:** `web/static/service-worker.js` (new file) ```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); } }) ); }) ); }); ``` ### 9.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 } ``` ### 9.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'); }); ``` --- ## 10. Dictionary Implementation ### 10.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. ### 10.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 }); } } }); } ``` --- ## 11. Reading Statistics Integration ### 11.1 Track Reading Speed **File:** `web/src/reader/reading-speed-tracker.ts` ```typescript // Track reading speed and update database // Reading speed tracker // Procedural implementation (no OOP) interface ReadingSpeedTrackerState { startTime: number | null; pagesRead: number; wordsRead: number; lastSync: number; mediaItemId: string; } function createReadingSpeedTracker(mediaItemId: string): ReadingSpeedTrackerState { return { startTime: null, pagesRead: 0, wordsRead: 0, lastSync: Date.now(), mediaItemId }; } function startReadingSession(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { return { ...state, startTime: Date.now(), pagesRead: 0, wordsRead: 0 }; } function recordPageTurn(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { if (!state.startTime) return state; const newPagesRead = state.pagesRead + 1; const now = Date.now(); if (newPagesRead % 5 === 0 || (now - state.lastSync) > 5 * 60 * 1000) { syncReadingSpeed({ ...state, pagesRead: newPagesRead }); return { ...state, pagesRead: newPagesRead, lastSync: now }; } return { ...state, pagesRead: newPagesRead }; } function recordWordsRead( state: ReadingSpeedTrackerState, wordCount: number ): ReadingSpeedTrackerState { return { ...state, wordsRead: state.wordsRead + wordCount }; } async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise { if (!state.startTime) return; const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60); const pagesPerMinute = state.pagesRead / minutesElapsed; const wordsPerMinute = state.wordsRead / minutesElapsed; await apiPut(`/readers/${state.mediaItemId}/reading-speed`, { pages_per_minute: pagesPerMinute, words_per_minute: wordsPerMinute, pages_read: state.pagesRead, total_reading_minutes: minutesElapsed }); } ``` --- ## 12. UI/UX Implementation ### 12.1 Reader Template (SSR) **File:** `templates/reader.templ` (new file) ```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() { } ``` --- ## 13. Integration Tests ### 13.1 Test Setup **File:** `cmd/server/tests/reader_test.go` (new file) Follow existing test patterns from `media_test.go` and `auth_test.go`: ```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 }) } ``` --- ## 14. Phased Implementation ### Phase 1: Infrastructure & Basic Reader (Week 1-2) - [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings) - [ ] Create reader service layer (`internal/services/reader_service.go`) - [ ] Create reader handlers (`internal/handlers/reader.go`) - [ ] 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 --- ## 15. Code Reuse Strategy ### 15.1 Reuse Existing Systems **WebSocket Sync (`internal/sync/websocket.go`)** - Reuse for real-time progress updates - Reuse for annotation sync - Reuse for bookmark sync **Progress Tracking (`internal/sync/progress.go`)** - Reuse EPUB CFI navigation logic - Reuse percentage calculation - Reuse chapter-relative page calculation **Format Handling (`internal/sync/format.go`)** - Reuse format detection logic - Reuse normalization functions **Annotation Tables (notes, highlights)** - Reuse existing database schema - Reuse existing API endpoints - Build UI on top of existing data **Theme System (11 dark themes)** - Reuse existing theme CSS variables - Apply theme to reader UI - Ensure consistency across app **Auth & User Management** - Reuse JWT middleware - Reuse user preferences - Reuse role-based access control ### 15.2 Surgical Code Edits **Avoid:** - ❌ Duplicating existing logic - ❌ Rewriting working code - ❌ Creating parallel systems **Do:** - ✅ Extend existing types where appropriate - ✅ Add new methods to existing services - ✅ Follow existing patterns and conventions - ✅ Use existing test helpers **Example - Extending sync/format.go:** ```go // EXISTING CODE in sync/format.go func CalculateProgress(currentPage, totalPages int) float64 { if totalPages == 0 { return 0 } return float64(currentPage) / float64(totalPages) * 100 } // NEW CODE - Add chapter-relative progress func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) (int, int) { chapterPage := currentPage - chapterStartPage + 1 return chapterPage, chapterPages } ``` --- ## 16. Bruno API Tests **File:** `bruno/reader/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/`. --- ## 17. Documentation ### 17.1 User Documentation **File:** `docs/user/reader.md` (new file) Comprehensive user guide covering: - How to open the reader - Navigation controls - Progress indicator modes - Settings options - Panel zoom for comics/manga - Dictionary lookup - Bookmarks, highlights, notes - Offline reading - Keyboard shortcuts ### 17.2 Developer Documentation **File:** `docs/contributing/reader-architecture.md` (new file) Technical documentation covering: - Reader architecture overview - Component structure - Data flow diagrams - Panel detection algorithms - Caching strategy - Offline support implementation - Testing strategy --- ## 18. Success Criteria ### 18.1 Functional Requirements - ✅ User can read ebooks (EPUB) with adjustable typography - ✅ User can read comics (CBZ/CBR/PDF) with panel zoom - ✅ User can read manga with RTL and vertical scroll modes - ✅ Progress syncs across devices via WebSocket - ✅ User can create bookmarks, highlights, notes - ✅ User can look up words in dictionary (offline) - ✅ Reader works offline for cached content - ✅ Settings persist across devices (DB) and browsers (localStorage) - ✅ 8 bundled libre reading fonts (no network requests) - ✅ UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes ### 18.2 Performance Requirements - ⚡ Initial page load: < 2 seconds - ⚡ Page turn (comics): < 500ms with 5-page cache - ⚡ Panel zoom animation: 300ms smooth - ⚡ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached) - ⚡ Offline cache hit: < 100ms ### 18.3 Quality Requirements - ✅ Zero TypeScript errors - ✅ All integration tests passing - ✅ Zero known security vulnerabilities - ✅ Mobile-responsive (320px - 4K) - ✅ Keyboard accessible - ✅ WCAG 2.1 AA compliant --- ## 19. Future Enhancements (Out of Scope for Initial Implementation) - TTS (Text-to-Speech) - user excluded - Advanced ML panel detection with custom model - Social features (share highlights, see friends' progress) - Advanced annotations (draw on pages, voice notes) - PDF form filling - EPUB audio/video media overlays - Advanced manga panel navigation (auto-detect panel order) - Reading goals and challenges - Social reading (book clubs, shared annotations) --- ## Conclusion This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The **universal reader architecture with pluggable parsers** provides the best balance of code reuse, maintainability, and extensibility. **Key principles:** - **Universal reader**: One rendering engine for all reflowable ebooks (EPUB, FB2, TXT, HTML, MOBI, AZW3, DOCX, RTF) - **Common Intermediate Format (CIF)**: Standardized HTML structure that all parsers produce - **Hybrid parsing**: Client-side for simple formats (~500 KB), server-side for complex formats (no 182 MB Calibre dependency) - **Procedural TypeScript**: Functions, not classes (per PROJECT_GUIDELINES.md) - **Surgical code reuse**: Extend existing systems (WebSocket sync, progress tracking, annotations) - **Progressive enhancement**: SSR-first with TypeScript enhancements - **Privacy-first**: Per-user settings with localStorage fallback - **Offline-capable**: PWA with service worker - **Libre fonts only**: 8 bundled open-source reading fonts - **Hybrid theming**: 11 themes for UI/comics, 5 reading-optimized themes for ebook text **Supported Formats:** | Format | Parser Location | Dependency Size | Status | |--------|-----------------|-----------------|--------| | **EPUB 2/3** | Client (TypeScript) | 0 KB (JSZip) | ✅ Planned | | **FB2** | Client (TypeScript) | 0 KB (XML) | ✅ Planned | | **TXT** | Client (TypeScript) | 0 KB | ✅ Planned | | **HTML** | Client (TypeScript) | 0 KB | ✅ Planned | | **MOBI** | Server (Go) | ~100 KB | ✅ Planned | | **AZW3** | Server (Go) | ~50 KB | ✅ Planned | | **DOCX** | Server (Go) | ~200 KB (mammoth) | ✅ Planned | | **RTF** | Server (Go) | ~50 KB | ✅ Planned | | **PDF** | Client (pdf.js) | ~500 KB | ✅ Planned | | **Comics** | Client (canvas) | 0 KB | ✅ Planned | | **Manga** | Client (extends comics) | 0 KB | ✅ Planned | **Total client-side dependencies: ~1 MB (vs. 182 MB for Calibre)** **Key design decisions:** - **Architecture**: Universal reader + parser pipeline (not separate readers) - **Parsing**: Hybrid (client for simple, server for complex) - **Code style**: Procedural TypeScript (no OOP per guidelines) - **Fonts**: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only - **Theming**: Hybrid - 11 themes for UI, 5 reading-optimized themes for text - **Typography**: Optimized for extended reading (Literata default) **Estimated timeline:** 8 weeks for full implementation **Next steps:** 1. Review and approve this plan 2. Begin Phase 1: Infrastructure & Basic Reader 3. Create database schema (add pdf_bookmarks table) 4. Implement parser manager and CIF types 5. Build universal reader shell (procedural style) 6. Implement parsers (start with EPUB, TXT - simplest first) 7. Add server-side parsers for complex formats (MOBI, AZW3, DOCX) --- *Plan created: 2025* *Last updated: 2025* *Major revision: Universal reader architecture + procedural TypeScript*