diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md index bad0976..3466e3a 100644 --- a/READER_IMPLEMENTATION_PLAN.md +++ b/READER_IMPLEMENTATION_PLAN.md @@ -1,54 +1,194 @@ + + # 📖 Bookhoard Reader Implementation Plan ## Overview -Build a modern, responsive web reader for ebooks, comics, and manga with full feature parity across all three media types. +Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types. **Design Philosophy:** -- Hybrid architecture: Shared infrastructure + specialized reader components -- Surgical code reuse: Leverage existing WebSocket sync, progress tracking, annotation systems -- Progressive enhancement: SSR-first with JavaScript enhancements -- Privacy-first: Per-user settings with localStorage fallback -- Offline-capable: PWA with offline dictionary +- **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 Component Structure +### 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) -├── ReaderShell.ts - UI shell, chrome control, routing -├── ProgressTracker.ts - Integration with existing reading_progress table -├── AnnotationManager.ts - Integration with existing notes/highlights tables -├── WebSocketSync.ts - Reuse existing sync system -├── SettingsManager.ts - Per-user preferences (DB + localStorage) -├── BookmarkManager.ts - Integration with existing bookmarks -└── ChapterDetector.ts - Chapter detection for all media types +├── 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 -Medium-Specific Readers -├── EbookReader/ -│ ├── EPUBParser.ts - Custom EPUB parsing (ZIP + XML) -│ ├── HTMLRenderer.ts - Browser-native HTML rendering -│ ├── CFINavigator.ts - EPUB CFI navigation (reuse existing sync/format.go logic) -│ ├── TypographyEngine.ts - Font rendering, theme integration -│ └── DictionaryPopup.ts - Offline dictionary lookup +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 │ -├── ComicReader/ -│ ├── ImageArchiveParser.ts - CBZ/CBR/PDF parsing -│ ├── CanvasRenderer.ts - Canvas rendering with lazy loading -│ ├── PanelDetector.ts - Grid-based + ML + manual override -│ ├── PanelNavigator.ts - Panel zoom with smooth animations -│ └── PageCache.ts - 5-page ahead cache +├── 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 │ -└── MangaReader/ (extends ComicReader) - ├── RTLNavigator.ts - Right-to-left navigation - ├── VerticalScrollMode.ts - Webtoon-style vertical scroll - └── PanelDetector.ts - Manga-aware panel detection +└── 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.2 Theming Strategy (Hybrid Approach) +### 1.3 Theming Strategy (Hybrid Approach) **Design Decision:** @@ -72,6 +212,14 @@ Bookhoard Reader uses a **hybrid theming approach** to balance user personalizat │ - 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 │ @@ -103,7 +251,7 @@ Bookhoard Reader uses a **hybrid theming approach** to balance user personalizat - `reading_theme`: Applied to ebook text content only (5 options) - Comics/manga: Use `chrome_theme` (all 11 themes work well) -### 1.3 Data Flow +### 1.4 Data Flow ``` User opens reader @@ -114,7 +262,7 @@ Verify access, fetch metadata, progress, bookmarks ↓ SSR render: templates/reader.templ with initial data ↓ -Frontend: Initialize appropriate reader (Ebook/Comic/Manga) +Frontend: Initialize appropriate reader (Ebook/PDF/Comic/Manga) ↓ Load content (lazy load + cache) ↓ @@ -186,6 +334,21 @@ CREATE TABLE reader_settings ( ); 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 @@ -243,10 +406,16 @@ func registerReaderRoutes(cfg *Config) { // Chapter metadata reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters) - // Panel data + // 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) @@ -258,6 +427,18 @@ func registerReaderRoutes(cfg *Config) { 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 @@ -361,20 +542,87 @@ web/src/reader/ **File:** `web/src/types/reader.d.ts` (new file) ```typescript -// Reader metadata (from API) +// ============================================================ +// 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'; + library_type: 'ebook' | 'comic' | 'manga' | 'pdf'; mime_type: string; file_path: string; chapter_metadata?: ChapterMetadata; total_pages?: number; } -// Chapter metadata +// ============================================================ +// Other Shared Types +// ============================================================ + interface ChapterMetadata { chapters: Chapter[]; } @@ -386,7 +634,6 @@ interface Chapter { page_count: number; } -// Panel data interface PanelData { media_item_id: string; page_number: number; @@ -397,14 +644,13 @@ interface PanelData { interface Panel { id: string; - x: number; // percentage (0-100) - y: number; // percentage (0-100) - width: number; // percentage (0-100) - height: number; // percentage (0-100) + x: number; + y: number; + width: number; + height: number; reading_order: number; } -// Reading speed interface ReadingSpeed { words_per_minute: number; pages_per_minute: number; @@ -413,7 +659,6 @@ interface ReadingSpeed { last_read_at: string; } -// Dictionary entry interface DictionaryEntry { word: string; definition: string; @@ -422,55 +667,511 @@ interface DictionaryEntry { etymology?: string; } -// Reader settings interface ReaderSettings { - // Display chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll'; progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; - // THEMING (Hybrid Approach) - // Chrome theme: Applied to reader UI (bars, panels, settings) - // Options: All 11 Bookhoard themes (tokyo-night, dracula, etc.) chrome_theme: string; - - // Reading theme: Applied to ebook text content only - // Options: Reading-optimized themes (light, sepia, dark, night, high-contrast) - // Comic/manga: Use chrome_theme (all 11 themes work well with visual content) reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; - // TYPOGRAPHY (Ebook Reading Fonts) - // 8 bundled libre fonts optimized for extended reading - // Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i) - // UI elements use Bookhoard's existing font stack (not these reading fonts) reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; font_size: number; line_height: number; margin_width: number; - // Navigation - tap_zone_size: number; // Percentage (0-100) + tap_zone_size: number; auto_scroll: boolean; panel_zoom_enabled: boolean; - // Manga/Comic specific double_page_spread: boolean; reading_direction: 'ltr' | 'rtl' | 'vertical'; - // Advanced + 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; } -// Progress indicator (KOReader-style) interface ProgressDisplay { mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; current: number; total: number; - label?: string; // e.g., "Chapter 5" - time_left?: string; // e.g., "2h 15m" + label?: string; + time_left?: string; } ``` -### 4.3 Reader Shell +### 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` @@ -483,7 +1184,7 @@ import { getReaderMetadata, updateReadingProgress } from "./api"; import { SettingsManager } from "./settings-manager"; import { ProgressIndicator } from "./progress-indicator"; -let currentReader: EbookReader | ComicReader | MangaReader | null = null; +let currentReader: EbookReader | PDFReader | ComicReader | MangaReader | null = null; function initializeReader(): void { const mediaItemId = document.body.dataset.mediaItemId; @@ -496,6 +1197,9 @@ function initializeReader(): void { case 'ebook': currentReader = new EbookReader(metadata); break; + case 'pdf': + currentReader = new PDFReader(metadata); + break; case 'comic': currentReader = new ComicReader(metadata); break; @@ -526,7 +1230,7 @@ Alpine.data('readerShell', () => ({ })); ``` -### 4.4 Progress Indicator (KOReader-style) +### 4.5 Progress Indicator (KOReader-style) **File:** `web/src/reader/progress-indicator.ts` @@ -595,7 +1299,7 @@ function cycleProgressMode(): void { } ``` -### 4.5 Settings Manager (DB + localStorage) +### 4.6 Settings Manager (DB + localStorage) **File:** `web/src/reader/settings-manager.ts` @@ -685,317 +1389,1575 @@ function getDefaultSettings(): ReaderSettings { ## 5. Ebook Reader Implementation -### 5.1 EPUB Parser +### 5.1 Parser Manager (Procedural) -**File:** `web/src/reader/ebook/epub-parser.ts` +**File:** `web/src/reader/parser-manager.ts` ```typescript -// EPUB parsing - ZIP + XML parsing for EPUB 2.0 and 3.0 +// Parser Manager - Routes files to appropriate parsers +// Procedural style: Functions, not classes -interface EPUBMetadata { - title: string; - author: string; - language: string; - publisher?: string; - description?: string; - identifier?: string; // ISBN, UUID, etc. +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'; } -interface EPUBSpineItem { - id: string; - href: string; - linear: string; // "yes" or "no" - properties?: string; +// ============================================================ +// 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; } -interface EPUBTableOfContents { - id: string; - label: string; - href: string; - children: EPUBTableOfContents[]; +export function requiresServerParsing(mimeType: string, extension: string): boolean { + const entry = detectParserFormat(mimeType, extension); + return entry?.side === 'server' || false; } -interface EPUBPackage { - metadata: EPUBMetadata; - spine: EPUBSpineItem[]; - toc: EPUBTableOfContents[]; - resources: Map; // All files (HTML, CSS, images, fonts) - coverImage?: Blob; +// ============================================================ +// 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); + } } -class EPUBParser { - private zip: JSZip | null = null; - private packageDoc: XMLDocument | null = null; +// ============================================================ +// Client-Side Parsing +// ============================================================ - async parse(epubBlob: Blob): Promise { - // EPUB is a ZIP file - const zip = new JSZip(); - this.zip = await zip.loadAsync(epubBlob); +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}`); + } +} - // Parse container.xml to find OPF file - const containerXml = await this.getFileContent('META-INF/container.xml'); - const containerDoc = this.parseXML(containerXml); - const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path'); +// ============================================================ +// Server-Side Parsing (API Call) +// ============================================================ - if (!opfPath) { - throw new Error('Invalid EPUB: no OPF file found'); - } +async function parseEbookOnServer(file: Blob, format: string): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('format', format); - // Parse OPF file - const opfXml = await this.getFileContent(opfPath); - this.packageDoc = this.parseXML(opfXml); + const response = await fetch('/api/readers/parse', { + method: 'POST', + body: formData, + }); - // Extract metadata - const metadata = this.parseMetadata(); - - // Parse spine (reading order) - const spine = this.parseSpine(); - - // Parse table of contents - const toc = await this.parseTOC(opfPath); - - // Load all resources - const resources = await this.loadResources(); - - // Extract cover image - const coverImage = await this.extractCover(); - - return { - metadata, - spine, - toc, - resources, - coverImage - }; + if (!response.ok) { + throw new Error(`Server parsing failed: ${response.statusText}`); } - private async getFileContent(path: string): Promise { - if (!this.zip) throw new Error('EPUB not loaded'); - - const file = this.zip.file(path); - if (!file) { - throw new Error(`File not found: ${path}`); - } - - return file.async('text'); - } - - private parseXML(xmlString: string): XMLDocument { - const parser = new DOMParser(); - return parser.parseFromString(xmlString, 'text/xml'); - } - - private parseMetadata(): EPUBMetadata { - if (!this.packageDoc) throw new Error('OPF not loaded'); - - const metadata = this.packageDoc.querySelector('metadata'); - if (!metadata) { - throw new Error('No metadata found in OPF'); - } - - const title = metadata.querySelector('title')?.textContent || ''; - const author = metadata.querySelector('creator')?.textContent || ''; - const language = metadata.querySelector('language')?.textContent || 'en'; - const publisher = metadata.querySelector('publisher')?.textContent || undefined; - const description = metadata.querySelector('description')?.textContent || undefined; - const identifier = metadata.querySelector('identifier')?.textContent || undefined; - - return { title, author, language, publisher, description, identifier }; - } - - private parseSpine(): EPUBSpineItem[] { - if (!this.packageDoc) throw new Error('OPF not loaded'); - - const spine = this.packageDoc.querySelector('spine'); - if (!spine) { - throw new Error('No spine found in OPF'); - } - - const manifest = this.packageDoc.querySelector('manifest'); - if (!manifest) { - throw new Error('No manifest found in OPF'); - } - - const items: EPUBSpineItem[] = []; - const spineItems = spine.querySelectorAll('itemref'); - - spineItems.forEach((itemref) => { - const idref = itemref.getAttribute('idref'); - if (!idref) return; - - const manifestItem = manifest.querySelector(`[id="${idref}"]`); - if (!manifestItem) return; - - const href = manifestItem.getAttribute('href'); - const linear = itemref.getAttribute('linear') || 'yes'; - const properties = itemref.getAttribute('properties') || undefined; - - if (href) { - items.push({ id: idref, href, linear, properties }); - } - }); - - return items; - } - - private async parseTOC(opfPath: string): Promise { - if (!this.packageDoc) throw new Error('OPF not loaded'); - - // Try EPUB 3.0 navigation document first - const navItem = this.packageDoc.querySelector('manifest item[properties~="nav"]'); - if (navItem) { - const navHref = navItem.getAttribute('href'); - if (navHref) { - const navPath = this.resolvePath(opfPath, navHref); - return this.parseNavTOC(navPath); - } - } - - // Fallback to EPUB 2.0 NCX - const ncxId = this.packageDoc.querySelector('spine')?.getAttribute('toc'); - if (ncxId) { - const ncxItem = this.packageDoc.querySelector(`manifest [id="${ncxId}"]`); - if (ncxItem) { - const ncxHref = ncxItem.getAttribute('href'); - if (ncxHref) { - const ncxPath = this.resolvePath(opfPath, ncxHref); - return this.parseNCXTOC(ncxPath); - } - } - } - - return []; - } - - private async parseNavTOC(navPath: string): Promise { - const navXml = await this.getFileContent(navPath); - const navDoc = this.parseXML(navXml); - const nav = navDoc.querySelector('nav'); - - if (!nav) return []; - - const items: EPUBTableOfContents[] = []; - const ol = nav.querySelector('ol'); - - if (ol) { - const lis = ol.querySelectorAll(':scope > li'); - for (const li of lis) { - const link = li.querySelector('a'); - if (link) { - const label = link.textContent || ''; - const href = link.getAttribute('href') || ''; - items.push({ id: href, label, href, children: [] }); - } - } - } - - return items; - } - - private async parseNCXTOC(ncxPath: string): Promise { - const ncxXml = await this.getFileContent(ncxPath); - const ncxDoc = this.parseXML(ncxXml); - const navMap = ncxDoc.querySelector('navMap'); - - if (!navMap) return []; - - return this.parseNCXNode(navMap); - } - - private parseNCXNode(node: Element): EPUBTableOfContents[] { - const items: EPUBTableOfContents[] = []; - const navPoints = node.querySelectorAll(':scope > navPoint'); - - navPoints.forEach((navPoint) => { - const label = navPoint.querySelector('navLabel text')?.textContent || ''; - const content = navPoint.querySelector('content'); - const href = content?.getAttribute('src') || ''; - const id = href; - - const children = this.parseNCXNode(navPoint); - - items.push({ id, label, href, children }); - }); - - return items; - } - - private async loadResources(): Promise> { - const resources = new Map(); - - if (!this.zip) return resources; - - // Load all files from the ZIP - const files = Object.keys(this.zip.files); - - for (const path of files) { - const file = this.zip.file(path); - if (file && !file.dir) { - const blob = await file.async('blob'); - resources.set(path, blob); - } - } - - return resources; - } - - private async extractCover(): Promise { - if (!this.packageDoc) return undefined; - - // Try cover-id metadata - const coverId = this.packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content'); - if (coverId) { - const coverItem = this.packageDoc.querySelector(`manifest [id="${coverId}"]`); - if (coverItem) { - const coverHref = coverItem.getAttribute('href'); - if (coverHref && this.zip) { - const coverFile = this.zip.file(coverHref); - if (coverFile) { - return coverFile.async('blob'); - } - } - } - } - - // Fallback: look for cover image in manifest - const coverItem = this.packageDoc.querySelector('manifest item[properties~="cover-image"]'); - if (coverItem) { - const coverHref = coverItem.getAttribute('href'); - if (coverHref && this.zip) { - const coverFile = this.zip.file(coverHref); - if (coverFile) { - return coverFile.async('blob'); - } - } - } - - return undefined; - } - - private resolvePath(basePath: string, relativePath: string): string { - const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); - return baseDir + relativePath; - } - - // Get a specific spine item as HTML document - async getSpineItem(spinItem: EPUBSpineItem): Promise { - if (!this.zip) throw new Error('EPUB not loaded'); - - const content = await this.getFileContent(spinItem.href); - const parser = new DOMParser(); - const doc = parser.parseFromString(content, 'text/html'); - - // Inject base URL for resolving relative links - const base = doc.createElement('base'); - base.href = spinItem.href; - doc.head.insertBefore(base, doc.head.firstChild); - - return doc; - } + return await response.json(); } ``` -### 5.2 HTML Renderer +--- + +### 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` @@ -1012,402 +2974,206 @@ interface RendererConfig { columnCount: 1 | 2; // Single or double column } -class HTMLRenderer { - private container: HTMLElement; - private config: RendererConfig; - private loadedFonts: Set = new Set(); +// HTML rendering with theme support, font loading, and image handling +// Procedural implementation (no OOP) - constructor(container: HTMLElement, config: RendererConfig) { - this.container = container; - this.config = config; - } +const loadedFonts = new Set(); - async renderDocument(doc: HTMLDocument): Promise { - // Apply theme - this.applyTheme(); +async function renderDocument( + container: HTMLElement, + doc: HTMLDocument, + config: RendererConfig +): Promise { + applyTheme(container, config.readingTheme); + applyTypography(container, config); + injectReaderStyles(container); + await loadEmbeddedFonts(doc); + processImages(doc); - // Apply typography settings - this.applyTypography(); + container.innerHTML = ''; + container.appendChild(doc.body); - // Inject custom styles for reader - this.injectReaderStyles(); + applyColumnLayout(container, config); +} - // Handle embedded fonts - await this.loadEmbeddedFonts(doc); - - // Handle images - this.processImages(doc); - - // Clear container and append content - this.container.innerHTML = ''; - this.container.appendChild(doc.body); - - // Apply column layout - this.applyColumnLayout(); - } - - private applyTheme(): void { - // IMPORTANT: Hybrid theming approach - // - // UI Chrome (reader shell, bars, panels): All 11 Bookhoard themes - // Ebook text content: Reading-optimized themes only (5 options) - // Comics/manga: All 11 Bookhoard themes (visual content works well with colors) - // - // Why? Long-form reading requires eye-comfort optimization. - // Popular ebook readers (Kindle, Kobo, Apple Books) offer 3-5 reading themes, - // not unlimited colors. Purple text for 300 pages causes eye fatigue. - - // Reading-optimized themes (ebooks only) - const readingThemes: Record> = { - 'light': { - '--bg-primary': '#ffffff', - '--text-primary': '#1a1a1a', - '--text-secondary': '#666666', - '--accent': '#0066cc' - }, - 'sepia': { - '--bg-primary': '#f4ecd8', - '--text-primary': '#5f4b32', - '--text-secondary': '#8b7355', - '--accent': '#8b4513' - }, - 'dark': { - '--bg-primary': '#1a1b26', - '--text-primary': '#c0caf5', - '--text-secondary': '#565f89', - '--accent': '#7aa2f7' - }, - 'night': { - '--bg-primary': '#0d1117', - '--text-primary': '#c9d1d9', - '--text-secondary': '#8b949e', - '--accent': '#58a6ff' - }, - 'high-contrast': { - '--bg-primary': '#000000', - '--text-primary': '#ffffff', - '--text-secondary': '#cccccc', - '--accent': '#ffff00' - } - }; - - const theme = readingThemes[this.config.readingTheme] || readingThemes['dark']; - - for (const [key, value] of Object.entries(theme)) { - this.container.style.setProperty(key, value); +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); } +} - private applyTypography(): void { - const style = document.createElement('style'); +function applyTypography(container: HTMLElement, config: RendererConfig): void { + const style = document.createElement('style'); + const fontStack = getFontStack(config.readingFont); - // Get font stack for selected reading font - const fontStack = getFontStack(this.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; + } - style.textContent = ` - .ebook-content { - font-family: ${fontStack}; - font-size: ${this.config.fontSize}px; - line-height: ${this.config.lineHeight}; - text-align: ${this.config.textAlign}; - padding: 0 ${this.config.marginWidth}px; - max-width: 100%; - overflow-wrap: break-word; - } + .ebook-content p { + margin-bottom: 1em; + text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; + } - .ebook-content p { - margin-bottom: 1em; - text-indent: ${this.config.textAlign === 'justify' ? '1.5em' : '0'}; - } + .ebook-content img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + } - .ebook-content img { - max-width: 100%; - height: auto; - display: block; - margin: 1em auto; - } + .ebook-content a { + color: var(--accent); + text-decoration: underline; + } - .ebook-content a { - color: var(--accent); - text-decoration: underline; - } + .ebook-content a:active { + color: var(--text-secondary); + } + `; - .ebook-content a:active { - color: var(--text-secondary); - } - `; + container.appendChild(style); +} - this.container.appendChild(style); - } +function injectReaderStyles(container: HTMLElement): void { + container.setAttribute('role', 'main'); + container.setAttribute('aria-label', 'Book content'); +} - private injectReaderStyles(): void { - // Add ARIA roles for accessibility - this.container.setAttribute('role', 'main'); - this.container.setAttribute('aria-label', 'Book content'); - } +async function loadEmbeddedFonts(doc: HTMLDocument): Promise { + const styleSheets = doc.querySelectorAll('style'); - private async loadEmbeddedFonts(doc: HTMLDocument): Promise { - // Find @font-face rules in document - const styleSheets = doc.querySelectorAll('style'); + for (const sheet of styleSheets) { + const fontFaceRegex = /@font-face\s*{([^}]+)}/g; + const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; - for (const 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); - for (const match of matches) { - const fontFace = match[1]; - const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); - - if (urlMatch) { - const fontUrl = urlMatch[1]; - await this.loadFont(fontUrl); - } + if (urlMatch) { + const fontUrl = urlMatch[1]; + await loadFont(fontUrl); } } } +} - private async loadFont(fontUrl: string): Promise { - if (this.loadedFonts.has(fontUrl)) return; +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); - this.loadedFonts.add(fontUrl); - } catch (error) { - console.error('Failed to load font:', fontUrl, error); - } + 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); } +} - private processImages(doc: HTMLDocument): void { - const images = doc.querySelectorAll('img'); +function processImages(doc: HTMLDocument): void { + const images = doc.querySelectorAll('img'); - images.forEach((img) => { - // Add loading="lazy" for performance - img.setAttribute('loading', 'lazy'); + images.forEach((img) => { + img.setAttribute('loading', 'lazy'); - // Add alt text if missing - if (!img.alt) { - img.alt = 'Image from book'; - } + if (!img.alt) { + img.alt = 'Image from book'; + } - // Make images clickable for full-screen view - img.style.cursor = 'pointer'; - img.addEventListener('click', () => { - this.showImageFullscreen(img.src); - }); + 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); } - private showImageFullscreen(src: string): void { - // Create modal for full-screen image viewing - const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; - modal.onclick = () => modal.remove(); - - const img = document.createElement('img'); - img.src = src; - img.className = 'max-w-full max-h-full object-contain'; - - modal.appendChild(img); - document.body.appendChild(modal); - } - - private applyColumnLayout(): void { - if (this.config.columnCount === 2) { - this.container.style.columnCount = '2'; - this.container.style.columnGap = `${this.config.marginWidth}px`; - this.container.style.columnRule = '1px solid var(--text-secondary)'; - } else { - this.container.style.columnCount = 'auto'; - } - } - - updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig }; - - // Re-render with new config - const currentDoc = this.container.querySelector('.ebook-content')?.ownerDocument; - if (currentDoc) { - this.renderDocument(currentDoc as HTMLDocument); - } - } + return updatedConfig; } ``` -### 5.3 CFI Navigation - -**File:** `web/src/reader/ebook/cfi-navigator.ts` - -```typescript -// EPUB CFI (Canonical Fragment Identifier) navigation -// Reuses logic from internal/sync/format.go - -// CFI format: /6/4[chap1ref]!/4[body01]/10/2/1:3[2] - -interface CFIComponent { - type: 'index' | 'indirection-step' | 'text-location'; - value: number; - id?: string; - textOffset?: number; -} - -class CFINavigator { - // Parse CFI string into components - static parseCFI(cfi: string): CFIComponent[] { - const components: CFIComponent[] = []; - - // Remove leading ! if present - const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi; - - // Split by / - const parts = cleanCFI.split('/').filter(Boolean); - - for (const part of parts) { - // Parse component - const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); - if (match) { - const component: CFIComponent = { - type: match[3] !== undefined ? 'text-location' : 'index', - value: parseInt(match[1], 10), - id: match[2] - }; - - if (match[3]) { - component.textOffset = parseInt(match[3], 10); - } - - components.push(component); - } - } - - return components; - } - - // Generate CFI for a specific text node - static generateCFI( - spineIndex: number, - elementPath: number[], - textOffset: number = 0, - spineItemId?: string - ): string { - let cfi = `/6/${spineIndex}`; - - if (spineItemId) { - cfi += `[${spineItemId}]`; - } - - for (const index of elementPath) { - cfi += `/${index}`; - } - - if (textOffset > 0) { - cfi += `:${textOffset}`; - } - - return cfi; - } - - // Navigate to CFI in document - static navigateToCFI(doc: Document, cfi: string): Element | Text | null { - const components = this.parseCFI(cfi); - - if (components.length === 0) return null; - - // First component is spine index (handled by caller) - // Remaining components navigate through DOM - - let current: Node | null = doc.body; - - for (let i = 1; i < components.length; i++) { - const component = components[i]; - - if (component.type === 'index') { - // Navigate to child index - if (current instanceof Element) { - const children = this.getElementChildren(current); - current = children[component.value] || null; - } - } - } - - return current as Element | Text; - } - - // Get CFI for current selection - static getSelectionCFI(doc: Document): string | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - - const range = selection.getRangeAt(0); - const startContainer = range.startContainer; - - // Build path to start container - const path: number[] = []; - let current: Node | null = startContainer; - - while (current && current !== doc.body) { - const parent = current.parentElement; - if (parent) { - const siblings = this.getElementChildren(parent); - const index = siblings.indexOf(current as Element); - path.unshift(index); - } - current = parent; - } - - // Generate CFI - const spineIndex = 0; // This would come from current spine item - const textOffset = range.startOffset; - - return this.generateCFI(spineIndex, path, textOffset); - } - - // Get percentage from CFI - static getPercentageFromCFI(cfi: string): number { - // Simplified: use text location as percentage - const components = this.parseCFI(cfi); - const textLocation = components.find(c => c.type === 'text-location'); - - if (textLocation && textLocation.textOffset !== undefined) { - // Assume 1000 characters per "page" for rough calculation - return Math.min((textLocation.textOffset / 10), 100); - } - - return 0; - } - - private static getElementChildren(element: Element): Element[] { - return Array.from(element.children).filter(el => - el.nodeType === Node.ELEMENT_NODE - ) as Element[]; - } - - // Compare two CFIs to determine reading order - static compareCFIs(cfi1: string, cfi2: string): number { - const components1 = this.parseCFI(cfi1); - const components2 = this.parseCFI(cfi2); - - const maxLen = Math.max(components1.length, components2.length); - - for (let i = 0; i < maxLen; i++) { - const comp1 = components1[i]; - const comp2 = components2[i]; - - if (!comp1) return -1; - if (!comp2) return 1; - - if (comp1.value !== comp2.value) { - return comp1.value - comp2.value; - } - } - - return 0; - } -} -``` - -### 5.4 Libre Reading Fonts (Bundled) +### 5.10 Libre Reading Fonts (Bundled) **8 Open Source Fonts Optimized for Extended Reading** @@ -1415,7 +3181,7 @@ All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard **Font Directory:** `web/static/fonts/` -#### 5.4.1 Font Acquisition & Installation +#### 5.10.1 Font Acquisition & Installation **Automated Setup Script** @@ -1604,7 +3370,7 @@ If you prefer manual setup or the script fails: | **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 | | **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 | -#### 5.4.2 Font Conversion Requirements +#### 5.10.2 Font Conversion Requirements **Required Tools:** @@ -1633,7 +3399,7 @@ pyftsubset input.otf --output-file=output.woff2 \ woff2_compress input.otf output.woff2 ``` -#### 5.4.3 Font Verification +#### 5.10.3 Font Verification **Verify fonts are working:** @@ -1672,7 +3438,7 @@ All fonts use SIL Open Font License 1.1 WOFF2 format, ~1.2MB total" ``` -#### 5.4.4 Font Loading in Templates +#### 5.10.4 Font Loading in Templates **File:** `templates/reader.templ` (updated) @@ -1695,7 +3461,7 @@ templ Reader(user User, metadata ReaderMetadata) { } ``` -#### 5.4.5 Alternative: Use Google Fonts CDN (Not Recommended) +#### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended) If you don't want to bundle fonts (slower initial load, privacy concerns): @@ -1711,7 +3477,7 @@ If you don't want to bundle fonts (slower initial load, privacy concerns): - ✅ Faster (no DNS lookup, no TLS handshake) - ✅ Control (exact versions, no breaking changes) -#### 5.4.6 Font Subsetting for Language Support +#### 5.10.6 Font Subsetting for Language Support **Full Unicode vs. Latin-1 Subset:** @@ -1731,7 +3497,7 @@ pyftsubset NotoSerif-Regular.ttf \ This reduces Noto Serif from ~180KB to ~50KB per style. -#### 5.4.7 Font Loading Performance +#### 5.10.7 Font Loading Performance **Critical Rendering Path Optimization:** @@ -2102,7 +3868,7 @@ export { READING_FONTS, preloadFonts, getFontStack }; - **Performance**: Preload default font (Literata) + user's preference - **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK) -### 5.5 Typography Engine +### 5.11 Typography Engine **File:** `web/src/reader/ebook/typography-engine.ts` @@ -2124,110 +3890,108 @@ interface TypographyConfig { fontSmoothing: 'auto' | 'antialiased' | 'subpixel-antialiased'; } -class TypographyEngine { - private container: HTMLElement; - private config: TypographyConfig; +// Typography engine for ebook text rendering +// Procedural implementation (no OOP) - constructor(container: HTMLElement, config: TypographyConfig) { - this.container = container; - this.config = config; +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); } - apply(): void { - const content = this.container.querySelector('.ebook-content'); - if (!content) return; + setLigatures(content as HTMLElement, config.ligatures); - // Get font stack for selected reading font - const fontStack = getFontStack(this.config.readingFont); - - // Apply base styles - content.setAttribute('style', ` - font-family: ${fontStack}; - font-size: ${this.config.fontSize}px; - line-height: ${this.config.lineHeight}; - text-align: ${this.config.textAlign}; - margin-top: ${this.config.marginTop}px; - margin-bottom: ${this.config.marginBottom}px; - margin-left: ${this.config.marginLeft}px; - margin-right: ${this.config.marginRight}px; - text-indent: ${this.config.textIndent}px; - -webkit-font-smoothing: ${this.config.fontSmoothing}; - -moz-osx-font-smoothing: ${this.config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'}; - `); - - // Apply hyphenation if enabled - if (this.config.hyphenate) { - this.enableHyphenation(content as HTMLElement); - } - - // Enable/disable ligatures - this.setLigatures(content as HTMLElement, this.config.ligatures); - - // Apply justification settings - if (this.config.textAlign === 'justify') { - this.enableJustification(content as HTMLElement); - } + if (config.textAlign === 'justify') { + enableJustification(content as HTMLElement); } +} - private enableHyphenation(element: HTMLElement): void { - // Use CSS hyphens with lang attribute - element.style.hyphens = 'auto'; - element.style.hyphenateLimitChars = '6 3 3'; // min 6, before 3, after 3 +function enableHyphenation(container: HTMLElement, element: HTMLElement): void { + element.style.hyphens = 'auto'; + element.style.hyphenateLimitChars = '6 3 3'; - // Add language attribute from EPUB metadata - const lang = this.container.closest('[data-language]')?.getAttribute('data-language') || 'en'; - element.setAttribute('lang', lang); + 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'; } +} - private setLigatures(element: HTMLElement, enabled: boolean): void { - if (enabled) { - element.style.fontVariantLigatures = 'common-ligatures'; - element.style.fontFeatureSettings = '"liga", "dlig"'; - } else { - element.style.fontVariantLigatures = 'no-common-ligatures'; - element.style.fontFeatureSettings = 'normal'; - } - } +function enableJustification(element: HTMLElement): void { + element.style.wordBreak = 'normal'; + element.style.overflowWrap = 'break-word'; + element.style.wordWrap = 'break-word'; + element.style.letterSpacing = '0.01em'; +} - private enableJustification(element: HTMLElement): void { - // Add proper word breaking for justified text - element.style.wordBreak = 'normal'; - element.style.overflowWrap = 'break-word'; - element.style.wordWrap = 'break-word'; +function updateTypographyConfig( + currentConfig: TypographyConfig, + newConfig: Partial +): TypographyConfig { + return { ...currentConfig, ...newConfig }; +} - // Adjust letter spacing for better appearance - element.style.letterSpacing = '0.01em'; - } +function measureReadingTime(container: HTMLElement, wordsPerMinute: number = 250): number { + const content = container.querySelector('.ebook-content'); + if (!content) return 0; - updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig }; - this.apply(); - } + const text = content.textContent || ''; + const words = text.split(/\s+/).length; + const minutes = words / wordsPerMinute; - // Measure reading time for current content - measureReadingTime(wordsPerMinute: number = 250): number { - const content = this.container.querySelector('.ebook-content'); - if (!content) return 0; + return Math.ceil(minutes); +} - const text = content.textContent || ''; - const words = text.split(/\s+/).length; - const minutes = words / wordsPerMinute; +function getWordCount(container: HTMLElement): number { + const content = container.querySelector('.ebook-content'); + if (!content) return 0; - return Math.ceil(minutes); - } - - // Get word count for current content - getWordCount(): number { - const content = this.container.querySelector('.ebook-content'); - if (!content) return 0; - - const text = content.textContent || ''; - return text.split(/\s+/).length; - } + const text = content.textContent || ''; + return text.split(/\s+/).length; } ``` -### 5.5 Search Within Ebook +### 5.12 Search Within Ebook **File:** `web/src/reader/ebook/search.ts` @@ -2240,221 +4004,208 @@ interface SearchResult { chapterTitle: string; } -class EbookSearcher { - private epubPackage: EPUBPackage; +// Search within ebook content +// Procedural implementation (no OOP) - constructor(epubPackage: EPUBPackage) { - this.epubPackage = epubPackage; +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; + } + } } - async search(query: string): Promise { - const results: SearchResult[] = []; - const lowerQuery = query.toLowerCase(); + return results; +} - // Search all spine items - for (const [index, spineItem] of this.epubPackage.spine.entries()) { - const doc = await this.getSpineItemDocument(spineItem); +async function getSpineItemDocument( + epubPackage: EPUBPackage, + spineItem: EPUBSpineItem +): Promise { + try { + const content = await epubPackage.resources.get(spineItem.href)?.text(); + if (!content) return null; - if (!doc) continue; + const parser = new DOMParser(); + return parser.parseFromString(content, 'text/html'); + } catch (error) { + console.error('Failed to load spine item:', spineItem.href, error); + return null; + } +} - // Get chapter title - const chapterTitle = this.getChapterTitle(spineItem); +function findTextNodes(root: Node): Text[] { + const textNodes: Text[] = []; - // Search in text nodes - const textNodes = this.findTextNodes(doc.body); - - for (const node of textNodes) { - const text = node.textContent || ''; - const lowerText = text.toLowerCase(); - - let foundAt = 0; - while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { - // Generate CFI for this match - const cfi = this.generateCFIForNode(node, foundAt); - - // Extract snippet with context - const snippet = this.extractSnippet(text, foundAt, query.length); - - results.push({ - cfi, - snippet, - chapterTitle - }); - - foundAt += lowerQuery.length; + 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; } - } - } - return results; - } - - private async getSpineItemDocument(spineItem: EPUBSpineItem): Promise { - try { - const content = await this.epubPackage.resources.get(spineItem.href)?.text(); - if (!content) return null; - - const parser = new DOMParser(); - return parser.parseFromString(content, 'text/html'); - } catch (error) { - console.error('Failed to load spine item:', spineItem.href, error); - return null; - } - } - - private findTextNodes(root: Node): Text[] { - const textNodes: Text[] = []; - - const walker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - { - acceptNode: (node) => { - // Skip script, style, and empty text nodes - const parent = node.parentElement; - if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { - return NodeFilter.FILTER_REJECT; - } - - if (!node.textContent?.trim()) { - return NodeFilter.FILTER_REJECT; - } - - return NodeFilter.FILTER_ACCEPT; + if (!node.textContent?.trim()) { + return NodeFilter.FILTER_REJECT; } - } - ); - let node: Node | null; - while ((node = walker.nextNode())) { - textNodes.push(node as Text); - } - - return textNodes; - } - - private generateCFIForNode(node: Text, offset: number): string { - // Build path to node - const path: number[] = []; - let current: Node | null = node; - - while (current && current.parentNode) { - const parent = current.parentNode; - const siblings = Array.from(parent.childNodes) - .filter(n => n.nodeType === Node.ELEMENT_NODE); - const index = siblings.indexOf(current as Node); - - path.unshift(index); - current = parent; - } - - // Get spine index (simplified - would need proper tracking) - const spineIndex = 0; - - return CFINavigator.generateCFI(spineIndex, path, offset); - } - - private extractSnippet(text: string, offset: number, length: number): string { - const contextBefore = 30; - const contextAfter = 50; - - const start = Math.max(0, offset - contextBefore); - const end = Math.min(text.length, offset + length + contextAfter); - - let snippet = text.substring(start, end); - - // Add ellipsis if truncated - if (start > 0) snippet = '...' + snippet; - if (end < text.length) snippet = snippet + '...'; - - return snippet; - } - - private getChapterTitle(spineItem: EPUBSpineItem): string { - // Find TOC entry for this spine item - for (const toc of this.epubPackage.toc) { - if (toc.href === spineItem.href) { - return toc.label; - } - - // Check children - for (const child of toc.children) { - if (child.href === spineItem.href) { - return child.label; - } + return NodeFilter.FILTER_ACCEPT; } } + ); - return 'Chapter ' + (this.epubPackage.spine.indexOf(spineItem) + 1); + 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.6 Copy Text Handler +### 5.13 Copy Text Handler **File:** `web/src/reader/ebook/copy-handler.ts` ```typescript // Handle text copying with citation -class CopyHandler { - private currentMediaItem: MediaItemSummary; +// Handle text copying with citation +// Procedural implementation (no OOP) - constructor(mediaItem: MediaItemSummary) { - this.currentMediaItem = mediaItem; +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; } +} - async copySelection(): Promise { +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(); - if (!selection || selection.rangeCount === 0) return false; + const selectedText = selection?.toString().trim(); - const selectedText = selection.toString(); - if (!selectedText.trim()) return false; - - // Create citation - const citation = this.createCitation(selectedText); - - try { - await navigator.clipboard.writeText(citation); - - // Show toast - showToast('Copied to clipboard', 'success'); - - return true; - } catch (error) { - console.error('Failed to copy:', error); - showToast('Failed to copy to clipboard', 'error'); - return false; + if (selectedText) { + e.preventDefault(); + await copySelection(mediaItem); } - } - - private createCitation(text: string): string { - const citation = `"${text}"\n`; - citation += `— ${this.currentMediaItem.title}`; - if (this.currentMediaItem.author) { - citation += ` by ${this.currentMediaItem.author}`; - } - citation += `\n(Source: Bookhoard)`; - - return citation; - } - - // Enable copy on right-click - enableContextMenuCopy(): void { - document.addEventListener('contextmenu', async (e) => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - - if (selectedText) { - e.preventDefault(); - await this.copySelection(); - } - }); - } + }); } ``` -### 5.7 View Modes +### 5.14 View Modes **File:** `web/src/reader/ebook/view-modes.ts` @@ -2463,172 +4214,2290 @@ class CopyHandler { type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; -class ViewModeManager { - private container: HTMLElement; - private currentMode: ViewMode = 'paginated'; +// Different viewing modes for ebooks +// Procedural implementation (no OOP) - constructor(container: HTMLElement) { - this.container = container; +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; } +} - setViewMode(mode: ViewMode): void { - this.currentMode = mode; - this.applyMode(); - } +function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void { + element.classList.add('paginated'); - private applyMode(): void { - const content = this.container.querySelector('.ebook-content'); - if (!content) return; + element.style.height = '100vh'; + element.style.overflow = 'hidden'; + element.style.columnCount = '1'; + element.style.columnGap = '0'; - // Reset all modes - content.classList.remove( - 'paginated', - 'scrolled', - 'single-column', - 'double-column' - ); + enablePagination(container, element); +} - // Apply current mode - switch (this.currentMode) { - case 'paginated': - this.applyPaginatedMode(content as HTMLElement); - break; - case 'scrolled': - this.applyScrolledMode(content as HTMLElement); - break; - case 'single-column': - this.applySingleColumn(content as HTMLElement); - break; - case 'double-column': - this.applyDoubleColumn(content as HTMLElement); - break; +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); } - } + }); - private applyPaginatedMode(element: HTMLElement): void { - element.classList.add('paginated'); - - element.style.height = '100vh'; - element.style.overflow = 'hidden'; - element.style.columnCount = '1'; - element.style.columnGap = '0'; - - // Enable page-by-page navigation - this.enablePagination(element); - } - - private applyScrolledMode(element: HTMLElement): void { - element.classList.add('scrolled'); - - element.style.height = 'auto'; - element.style.overflowY = 'auto'; - element.style.columnCount = '1'; - - // Disable pagination - this.disablePagination(element); - } - - private applySingleColumn(element: HTMLElement): void { - element.classList.add('single-column'); - - element.style.columnCount = '1'; - element.style.columnGap = '0'; - element.style.maxWidth = '800px'; - element.style.margin = '0 auto'; - } - - private applyDoubleColumn(element: HTMLElement): void { - element.classList.add('double-column'); - - element.style.columnCount = '2'; - element.style.columnGap = '60px'; - element.style.columnRule = '1px solid var(--text-secondary)'; - element.style.maxWidth = '1400px'; - element.style.margin = '0 auto'; - } - - private enablePagination(element: HTMLElement): void { - // Calculate pages based on content height - const totalHeight = element.scrollHeight; - const pageHeight = element.clientHeight; - const pageCount = Math.ceil(totalHeight / pageHeight); - - // Add pagination controls - this.addPaginationControls(pageCount); - } - - private disablePagination(element: HTMLElement): void { - // Remove pagination controls - const controls = this.container.querySelector('.pagination-controls'); - controls?.remove(); - } - - private addPaginationControls(pageCount: number): void { - let currentPage = 1; - - // Create controls UI - const controls = document.createElement('div'); - controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t'; - controls.innerHTML = ` - - Page ${currentPage} of ${pageCount} - - `; - - // Add event listeners - controls.querySelector('.prev-page')?.addEventListener('click', () => { - if (currentPage > 1) { - currentPage--; - this.goToPage(currentPage); - } - }); - - controls.querySelector('.next-page')?.addEventListener('click', () => { - if (currentPage < pageCount) { - currentPage++; - this.goToPage(currentPage); - } - }); - - this.container.appendChild(controls); - } - - private goToPage(pageNumber: number): void { - const content = this.container.querySelector('.ebook-content') as HTMLElement; - if (!content) return; - - const pageHeight = content.clientHeight; - const scrollTop = (pageNumber - 1) * pageHeight; - - content.scrollTo({ - top: scrollTop, - behavior: 'smooth' - }); - - // Update page info - const pageInfo = this.container.querySelector('.page-info'); - if (pageInfo) { - pageInfo.textContent = `Page ${pageNumber} of ${this.getTotalPageCount()}`; + 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'); } - private getTotalPageCount(): number { - const content = this.container.querySelector('.ebook-content') as HTMLElement; - if (!content) return 1; + // Check cache + if (pdfState.pages.has(pageNumber)) { + return pdfState.pages.get(pageNumber)!; + } - const totalHeight = content.scrollHeight; - const pageHeight = content.clientHeight; + // Load page + const page = await pdfState.doc.getPage(pageNumber); + pdfState.pages.set(pageNumber, page); - return Math.ceil(totalHeight / pageHeight); + 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. Panel Detection Implementation +### 6.4 PDF Navigation (Procedural) -### 5.1 Grid-Based Detection (Primary) +**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` @@ -2718,7 +6587,7 @@ function mergeAdjacentPanels(panels: Panel[]): Panel[] { } ``` -### 5.2 ML-Based Detection (Enhancement) +### 7.2 ML-Based Detection (Enhancement) **File:** `web/src/reader/comic/panel-ml-detector.ts` @@ -2771,7 +6640,7 @@ async function detectPanelsML(imageData: ImageData): Promise { } ``` -### 5.3 Manual Override +### 7.3 Manual Override **File:** `web/src/reader/comic/panel-editor.ts` @@ -2850,105 +6719,124 @@ async function saveManualPanel(panel: Panel): Promise { --- -## 6. Lazy Loading & Caching +## 8. Lazy Loading & Caching -### 6.1 Page Cache (5-Page Ahead) +### 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 -class PageCache { - private cache: Map = new Map(); - private loading: Set = new Set(); - private maxAhead = 5; +// Lazy-loading page cache with 5-page ahead prefetch +// Procedural implementation (no OOP) - constructor(private mediaItemId: string) {} +interface PageCacheState { + cache: Map; + loading: Set; + maxAhead: number; + mediaItemId: string; +} - async getPage(pageNumber: number): Promise { - // Check cache first - if (this.cache.has(pageNumber)) { - return this.cache.get(pageNumber)!; +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}` } } + ); - // Check if already loading - if (this.loading.has(pageNumber)) { - // Wait for existing load - return new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (this.cache.has(pageNumber)) { - clearInterval(checkInterval); - resolve(this.cache.get(pageNumber)!); - } - }, 100); + 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); }); } - - // Load page - this.loading.add(pageNumber); - const img = await this.loadPage(pageNumber); - this.cache.set(pageNumber, img); - this.loading.delete(pageNumber); - - // Prefetch next pages - this.prefetch(pageNumber + 1); - - // Clean up old pages (keep last 10) - this.cleanup(pageNumber); - - return img; } +} - private async loadPage(pageNumber: number): Promise { - const token = localStorage.getItem('token'); - const response = await fetch( - `/api/readers/${this.mediaItemId}/pages/${pageNumber}`, - { - headers: { Authorization: `Bearer ${token}` } - } - ); +function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState { + const keepPages = 10; + const newCache = new Map(state.cache); - if (!response.ok) { - throw new Error(`Failed to load page ${pageNumber}`); - } - - const blob = await response.blob(); - const img = new Image(); - img.src = URL.createObjectURL(blob); - await new Promise((resolve) => { - img.onload = resolve; - }); - return img; - } - - private prefetch(startPage: number): void { - for (let i = startPage; i < startPage + this.maxAhead; i++) { - if (!this.cache.has(i) && !this.loading.has(i)) { - // Start loading in background - this.loadPage(i).then((img) => { - this.cache.set(i, img); - }); - } + for (const [page] of state.cache) { + if (page < currentPage - keepPages) { + newCache.delete(page); } } - private cleanup(currentPage: number): void { - const keepPages = 10; - for (const [page,] of this.cache) { - if (page < currentPage - keepPages) { - this.cache.delete(page); - } - } - } + return { ...state, cache: newCache }; } ``` --- -## 7. Offline Support (PWA) +## 9. Offline Support (PWA) -### 7.1 Service Worker +### 9.1 Service Worker **File:** `web/static/service-worker.js` (new file) @@ -3028,7 +6916,7 @@ self.addEventListener('activate', (event) => { }); ``` -### 7.2 PWA Manifest +### 9.2 PWA Manifest **File:** `web/static/manifest.json` (new file) @@ -3057,7 +6945,7 @@ self.addEventListener('activate', (event) => { } ``` -### 7.3 Register Service Worker +### 9.3 Register Service Worker **File:** `web/src/reader/offline-manager.ts` (new file) @@ -3097,9 +6985,9 @@ window.addEventListener('offline', () => { --- -## 8. Dictionary Implementation +## 10. Dictionary Implementation -### 8.1 Dictionary Data +### 10.1 Dictionary Data **File:** `web/static/dictionary/en-US.json` (new file) @@ -3118,7 +7006,7 @@ Compressed dictionary data with common words. Format: Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage. -### 8.2 Dictionary Popup +### 10.2 Dictionary Popup **File:** `web/src/reader/ebook/dictionary-popup.ts` @@ -3186,68 +7074,90 @@ function handleTextSelection(): void { --- -## 9. Reading Statistics Integration +## 11. Reading Statistics Integration -### 9.1 Track Reading Speed +### 11.1 Track Reading Speed **File:** `web/src/reader/reading-speed-tracker.ts` ```typescript // Track reading speed and update database -class ReadingSpeedTracker { - private startTime: number | null = null; - private pagesRead = 0; - private wordsRead = 0; - private lastSync = Date.now(); +// Reading speed tracker +// Procedural implementation (no OOP) - constructor(private mediaItemId: string) {} +interface ReadingSpeedTrackerState { + startTime: number | null; + pagesRead: number; + wordsRead: number; + lastSync: number; + mediaItemId: string; +} - startReadingSession(): void { - this.startTime = Date.now(); - this.pagesRead = 0; - this.wordsRead = 0; +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 }; } - recordPageTurn(): void { - if (!this.startTime) return; + return { ...state, pagesRead: newPagesRead }; +} - this.pagesRead++; +function recordWordsRead( + state: ReadingSpeedTrackerState, + wordCount: number +): ReadingSpeedTrackerState { + return { + ...state, + wordsRead: state.wordsRead + wordCount + }; +} - // Sync every 5 pages or every 5 minutes - const now = Date.now(); - if (this.pagesRead % 5 === 0 || (now - this.lastSync) > 5 * 60 * 1000) { - this.sync(); - this.lastSync = now; - } - } +async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise { + if (!state.startTime) return; - recordWordsRead(wordCount: number): void { - this.wordsRead += wordCount; - } + const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60); + const pagesPerMinute = state.pagesRead / minutesElapsed; + const wordsPerMinute = state.wordsRead / minutesElapsed; - private async sync(): void { - if (!this.startTime) return; - - const minutesElapsed = (Date.now() - this.startTime) / (1000 * 60); - const pagesPerMinute = this.pagesRead / minutesElapsed; - const wordsPerMinute = this.wordsRead / minutesElapsed; - - await apiPut(`/readers/${this.mediaItemId}/reading-speed`, { - pages_per_minute: pagesPerMinute, - words_per_minute: wordsPerMinute, - pages_read: this.pagesRead, - total_reading_minutes: minutesElapsed - }); - } + await apiPut(`/readers/${state.mediaItemId}/reading-speed`, { + pages_per_minute: pagesPerMinute, + words_per_minute: wordsPerMinute, + pages_read: state.pagesRead, + total_reading_minutes: minutesElapsed + }); } ``` --- -## 10. UI/UX Implementation +## 12. UI/UX Implementation -### 10.1 Reader Template (SSR) +### 12.1 Reader Template (SSR) **File:** `templates/reader.templ` (new file) @@ -3434,9 +7344,9 @@ templ DictionaryPopup() { --- -## 11. Integration Tests +## 13. Integration Tests -### 11.1 Test Setup +### 13.1 Test Setup **File:** `cmd/server/tests/reader_test.go` (new file) @@ -3513,7 +7423,7 @@ func TestReaderEndpoints(t *testing.T) { --- -## 12. Phased Implementation +## 14. Phased Implementation ### Phase 1: Infrastructure & Basic Reader (Week 1-2) - [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings) @@ -3565,9 +7475,9 @@ func TestReaderEndpoints(t *testing.T) { --- -## 13. Code Reuse Strategy +## 15. Code Reuse Strategy -### 13.1 Reuse Existing Systems +### 15.1 Reuse Existing Systems **WebSocket Sync (`internal/sync/websocket.go`)** - Reuse for real-time progress updates @@ -3598,7 +7508,7 @@ func TestReaderEndpoints(t *testing.T) { - Reuse user preferences - Reuse role-based access control -### 13.2 Surgical Code Edits +### 15.2 Surgical Code Edits **Avoid:** - ❌ Duplicating existing logic @@ -3631,7 +7541,7 @@ func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) ( --- -## 14. Bruno API Tests +## 16. Bruno API Tests **File:** `bruno/reader/reader.bru` (new folder) @@ -3655,9 +7565,9 @@ Follow existing Bruno patterns from `bruno/media/` and `bruno/auth/`. --- -## 15. Documentation +## 17. Documentation -### 15.1 User Documentation +### 17.1 User Documentation **File:** `docs/user/reader.md` (new file) @@ -3672,7 +7582,7 @@ Comprehensive user guide covering: - Offline reading - Keyboard shortcuts -### 15.2 Developer Documentation +### 17.2 Developer Documentation **File:** `docs/contributing/reader-architecture.md` (new file) @@ -3687,9 +7597,9 @@ Technical documentation covering: --- -## 16. Success Criteria +## 18. Success Criteria -### Functional Requirements +### 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 @@ -3701,14 +7611,14 @@ Technical documentation covering: - ✅ 8 bundled libre reading fonts (no network requests) - ✅ UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes -### Performance Requirements +### 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 -### Quality Requirements +### 18.3 Quality Requirements - ✅ Zero TypeScript errors - ✅ All integration tests passing - ✅ Zero known security vulnerabilities @@ -3718,7 +7628,7 @@ Technical documentation covering: --- -## 17. Future Enhancements (Out of Scope for Initial Implementation) +## 19. Future Enhancements (Out of Scope for Initial Implementation) - TTS (Text-to-Speech) - user excluded - Advanced ML panel detection with custom model @@ -3734,34 +7644,59 @@ Technical documentation covering: ## Conclusion -This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The hybrid architecture balances code reuse with medium-specific optimization, while the phased approach allows for incremental development and testing. +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:** -- Reuse existing systems (WebSocket sync, progress tracking, annotations) -- Surgical code edits (extend, don't duplicate) -- Follow existing patterns (service layer, test patterns) -- Progressive enhancement (SSR-first, JavaScript enhancements) -- Privacy-first (per-user settings, localStorage fallback) -- Offline-capable (PWA with service worker) -- Libre fonts only (8 bundled open-source reading fonts) -- Hybrid theming (11 themes for UI/comics, 5 reading-optimized themes for ebook text) +- **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:** -- **Fonts:** 8 libre fonts bundled (~1.2MB WOFF2), standard weights only (400, 400i, 700, 700i) -- **Theming:** Hybrid approach - all 11 Bookhoard themes for UI/chrome, 5 reading-optimized themes for ebook text -- **Typography:** Optimized for extended reading (Literata default, designed for Google Play Books) -- **UI Elements:** Share fonts with rest of Bookhoard app (not reading fonts) +- **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 -4. Implement service layer and handlers -5. Build reader template and frontend infrastructure +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*