# πŸ“– Bookhoard Reader Implementation Plan ## Overview Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types. **Design Philosophy:** - **Universal reader architecture**: One rendering engine with pluggable parser components - **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure - **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT) - **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md) - **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems - **Progressive enhancement**: SSR-first with TypeScript enhancements - **Privacy-first**: Per-user settings with localStorage fallback - **Offline-capable**: PWA with offline dictionary - **Full PDF support**: Mozilla pdf.js for text selection, highlights, search - **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, navigator, copy support - **Modular dockable panels**: TOC, Settings, Navigator, Bookmarks - each independent, dockable to either side, window-shade 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 { ... } ``` --- ## Phase 0: Prerequisites & Setup ⚠️ **MUST COMPLETE FIRST** **IMPORTANT:** Do not start implementation until these prerequisites are complete. Failure to complete these steps will result in compilation errors and missing functionality. ### 0.1 Database Schema Setup **Step 1:** The database tables are already defined in **Section 2.1** of this document. Copy the SQL from **Section 2.1 (lines 563-641)** and add it to: **File:** `database/schema/schema.sql` (modify existing) Add the SQL at the end of the file, before the index section (around line 1105). **Step 2:** Also add the ALTER TABLE statement from **Section 2.2** to add `chapter_metadata` column to `media_items` table. **Step 3:** After adding the tables, regenerate database queries: ```bash cd /home/nymusicman/Code/bookhoard/database sqlc generate ``` **Step 4:** Update your local database: **Option 1: Recreate database (LOSES ALL DATA - Recommended for development):** ```bash podman compose down -v # Delete all volumes podman compose up -d # Start with fresh schema ``` **Option 2: Manual SQL migration (preserves data):** ```bash podman exec bookhoard_db psql -U postgres -d bookhoard # Then paste the SQL from Section 2.1 and 2.2 ``` ### 0.2 Database Queries Setup **File:** `internal/database/queries/queries.sql` (modify existing) Add these queries to support the reader functionality: ```sql -- name: GetPanelData :one SELECT * FROM panel_data WHERE media_item_id = $1 AND page_number = $2; -- name: UpsertPanelData :one INSERT INTO panel_data (media_item_id, page_number, detection_method, panels) VALUES ($1, $2, $3, $4) ON CONFLICT (media_item_id, page_number) DO UPDATE SET detection_method = EXCLUDED.detection_method, panels = EXCLUDED.panels, updated_at = NOW() RETURNING *; -- name: GetReadingSpeed :one SELECT * FROM reading_speed WHERE user_id = $1 AND media_item_id = $2; -- name: CreateReadingSpeed :one INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: UpdateReadingSpeed :one UPDATE reading_speed SET pages_per_minute = $3, pages_read = pages_read + $4, total_reading_minutes = total_reading_minutes + $5, last_read_at = $6, updated_at = NOW() WHERE user_id = $1 AND media_item_id = $2 RETURNING *; -- name: GetDictionaryEntry :one SELECT * FROM dictionary_cache WHERE word = $1; -- name: CreateDictionaryEntry :one INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at) VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING *; -- name: UpdateDictionaryAccessed :one UPDATE dictionary_cache SET accessed_at = NOW() WHERE word = $1 RETURNING *; -- name: GetReaderSettings :one SELECT setting_value FROM reader_settings WHERE user_id = $1 AND setting_key = 'reader_settings'; -- name: UpsertReaderSettings :one INSERT INTO reader_settings (user_id, setting_key, setting_value) VALUES ($1, 'reader_settings', $2) ON CONFLICT (user_id, setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW() RETURNING *; -- name: GetMediaBookmarks :many SELECT * FROM media_bookmarks WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; -- name: CreateMediaBookmark :one INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- name: DeleteMediaBookmark :exec DELETE FROM media_bookmarks WHERE id = $1; -- name: UpdateMediaBookmark :one UPDATE media_bookmarks SET title = $2, notes = $3, position = $4, updated_at = NOW() WHERE id = $1 AND user_id = $5 RETURNING *; ``` **After adding the queries, regenerate:** ```bash cd /home/nymusicman/Code/bookhoard/database sqlc generate ``` ### 0.3 Frontend Dependencies **File:** `package.json` (modify existing) Add these dependencies to the `dependencies` section: ```json { "dependencies": { "jszip": "^3.10.1", "pdfjs-dist": "^3.11.174" } } ``` **Then install:** ```bash npm install ``` ### 0.4 Directory Structure **Create the required directory structure:** ```bash # Frontend reader directories mkdir -p web/src/reader/ebook mkdir -p web/src/reader/comic mkdir -p web/src/reader/manga mkdir -p web/src/reader/pdf # Fonts directory mkdir -p web/static/fonts # Bruno API test directory mkdir -p bruno/reader ``` ### 0.5 Pre-Implementation Checklist Before starting Phase 1, verify all items are complete: - [ ] **Database**: Added SQL from **Section 2.1** to `database/schema/schema.sql` - [ ] **Database**: Added ALTER TABLE from **Section 2.2** to `database/schema/schema.sql` - [ ] **Database**: Added queries to `internal/database/queries/queries.sql` (from this section) - [ ] **Database**: Ran `sqlc generate` to regenerate Go code - [ ] **Database**: Updated local database (recreated or migrated) - [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json` - [ ] **Frontend**: Ran `npm install` to install dependencies - [ ] **Structure**: Created all required directories (mkdir commands above) - [ ] **Verification**: Can run `go build ./...` without errors - [ ] **Verification**: Database tables exist (check with `\dt` in psql) ### 0.6 Verification Commands **Verify database tables exist:** ```bash podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks" ``` **Expected output should show 5 tables.** **Verify queries were generated:** ```bash grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed\|GetDictionaryEntry\|GetReaderSettings\|GetMediaBookmarks" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go # Should return count > 0 (at least 10-12 matches) ``` **Verify frontend dependencies:** ```bash npm list jszip pdfjs-dist # Should show versions, not "(empty)" ``` **Expected output:** ``` bookhoard@1.0.0 /home/nymusicman/Code/bookhoard β”œβ”€β”€ jszip@3.10.1 └── pdfjs-dist@3.11.174 ``` **Verify Go compilation:** ```bash go build ./... # Should complete without errors ``` **Verify directories exist:** ```bash ls -la web/src/reader/ | grep -E "ebook|comic|manga|pdf" # Should show 4 directories ``` ### 0.7 Troubleshooting **Problem:** `sqlc generate` fails with "undefined type" **Solution:** Make sure you copied the SQL from Section 2.1 EXACTLY as written **Problem:** Database tables don't show up after recreation **Solution:** Check that SQL was added BEFORE the index section in schema.sql **Problem:** `npm install` fails **Solution:** Try `npm cache clean --force` then `npm install` again **Problem:** `go build` fails with "undefined: GetPanelData" **Solution:** Make sure you ran `sqlc generate` after adding queries **Problem:** psql shows "column does not exist" for chapter_metadata **Solution:** Make sure you ran the ALTER TABLE command from Section 2.2 --- **File:** `internal/database/queries/queries.sql` (modify existing) **Add these queries to the queries.sql file:** ```sql -- name: GetPanelData :one SELECT * FROM panel_data WHERE media_item_id = $1 AND page_number = $2; -- name: UpsertPanelData :one INSERT INTO panel_data (media_item_id, page_number, detection_method, panels) VALUES ($1, $2, $3, $4) ON CONFLICT (media_item_id, page_number) DO UPDATE SET detection_method = EXCLUDED.detection_method, panels = EXCLUDED.panels, updated_at = NOW() RETURNING *; -- name: GetReadingSpeed :one SELECT * FROM reading_speed WHERE user_id = $1 AND media_item_id = $2; -- name: CreateReadingSpeed :one INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: UpdateReadingSpeed :one UPDATE reading_speed SET pages_per_minute = $3, pages_read = pages_read + $4, total_reading_minutes = total_reading_minutes + $5, last_read_at = $6, updated_at = NOW() WHERE user_id = $1 AND media_item_id = $2 RETURNING *; -- name: GetDictionaryEntry :one SELECT * FROM dictionary_cache WHERE word = $1; -- name: CreateDictionaryEntry :one INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at) VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING *; -- name: UpdateDictionaryAccessed :one UPDATE dictionary_cache SET accessed_at = NOW() WHERE word = $1 RETURNING *; -- name: GetReaderSettings :one SELECT setting_value FROM reader_settings WHERE user_id = $1 AND setting_key = 'reader_settings'; -- name: UpsertReaderSettings :one INSERT INTO reader_settings (user_id, setting_key, setting_value) VALUES ($1, 'reader_settings', $2) ON CONFLICT (user_id, setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW() RETURNING *; -- name: GetMediaBookmarks :many SELECT * FROM media_bookmarks WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; -- name: CreateMediaBookmark :one INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- name: DeleteMediaBookmark :exec DELETE FROM media_bookmarks WHERE id = $1; ``` **Regenerate queries after adding:** ```bash cd /home/nymusicman/Code/bookhoard/database sqlc generate ``` ### 0.3 Frontend Dependencies **File:** `package.json` (modify existing) **Add these dependencies to the dependencies section:** ```json { "dependencies": { "jszip": "^3.10.1", "pdfjs-dist": "^3.11.174" } } ``` **Then install:** ```bash npm install ``` ### 0.4 Directory Structure **Create the required directory structure:** ```bash # Frontend reader directories mkdir -p web/src/reader/ebook mkdir -p web/src/reader/comic mkdir -p web/src/reader/manga mkdir -p web/src/reader/pdf # Fonts directory mkdir -p web/static/fonts # Bruno API test directory mkdir -p bruno/reader ``` ### 0.5 Pre-Implementation Checklist Before starting Phase 1, verify all items are complete: - [ ] **Database**: Added 5 new tables to `database/schema/schema.sql` - [ ] **Database**: Added SQL queries to `internal/database/queries/queries.sql` - [ ] **Database**: Ran `sqlc generate` to regenerate Go code - [ ] **Database**: Updated local database (recreated or migrated) - [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json` - [ ] **Frontend**: Ran `npm install` to install dependencies - [ ] **Structure**: Created all required directories - [ ] **Verification**: Can run `go build ./...` without errors - [ ] **Verification**: Database tables exist (check with `\dt` in psql) ### 0.6 Verification Commands **Verify database tables exist:** ```bash podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks" ``` **Verify queries were generated:** ```bash grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go # Should return count > 0 ``` **Verify frontend dependencies:** ```bash npm list jszip pdfjs-dist # Should show versions, not "(empty)" ``` **Verify Go compilation:** ```bash go build ./... # Should complete without errors ``` --- ## 1. Architecture ### 1.1 Universal Reader with Pluggable Parsers **Architectural Decision: Single Reader + Parser Pipeline** Instead of separate readers for each format, we use **one universal reader** with **pluggable parsers** that convert all formats to a **Common Intermediate Format (CIF)**. ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Universal Ebook Reader (Single) β”‚ β”‚ - HTML Renderer (shared) β”‚ β”‚ - Typography Engine (shared) β”‚ β”‚ - Progress Tracker (shared) β”‚ β”‚ - Annotation Manager (shared) β”‚ β”‚ - Navigation Controls (shared) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Common Intermediate Format (CIF) β”‚ β”‚ - Standardized HTML structure β”‚ β”‚ - Universal metadata schema β”‚ β”‚ - Unified navigation (TOC) β”‚ β”‚ - Consistent resource loading β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Parser Manager (Router) β”‚ β”‚ Detects format β†’ Routes to appropriate parser β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ EPUB β”‚ FB2 β”‚ TXT β”‚ HTML β”‚ ← Client-side β”‚ Parser β”‚ Parser β”‚ Parser β”‚ Parser β”‚ (TypeScript) β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ MOBI β”‚ AZW3 β”‚ DOCX β”‚ RTF β”‚ ← Server-side β”‚ Parser β”‚ Parser β”‚ Parser β”‚ Parser β”‚ (Go backend) β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ PDF and Comics use dedicated readers (not CIF pipeline): - PDFReader (pdf.js) - Fixed-layout documents - ComicReader (canvas) - Image archives - MangaReader (extends Comic) - RTL/vertical modes ``` **Why This Approach?** 1. **Code Reuse**: One reader implementation for all reflowable ebooks 2. **Consistency**: All formats have identical UI/UX 3. **Maintainability**: Fix bug once, applies to all formats 4. **Extensibility**: Add new format by implementing parser interface 5. **Performance**: Client-side for simple formats, server-side for complex ### 1.2 Component Structure ``` Reader Infrastructure (Shared) β”œβ”€β”€ reader-shell.ts - UI shell, chrome control, routing β”œβ”€β”€ panel-dock-system.ts - Modular dockable panel system β”‚ β”œβ”€β”€ panel-container.ts - Base panel container with dock logic β”‚ β”‚ - Lock toggle: prevents accidental drag/move β”‚ β”‚ - Snap-back: returns to last valid position if dropped in invalid area β”‚ β”‚ - Dock zones: left edge, right edge, valid drop targets β”‚ β”‚ - Drag handlers: mouse/touch drag to reposition β”‚ β”‚ - Drop zone detection: validates drop position β”‚ β”‚ - Persist state: saves to settings on move/lock/collapse β”‚ β”œβ”€β”€ window-shade.ts - Vertical collapse to title bar β”‚ β”‚ - Animated collapse/expand β”‚ β”‚ - State persistence β”‚ └── panel-state.ts - Per-user layout persistence (includes lock state) β”œβ”€β”€ 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 Dockable Panel Components (Reusable) β”œβ”€β”€ toc-panel.ts - Table of Contents (dockable left/right, window-shade) β”œβ”€β”€ settings-panel.ts - Reader settings (dockable left/right, window-shade) β”œβ”€β”€ navigator-panel.ts - Page navigator with viewport box (dockable left/right, window-shade) └── bookmarks-panel.ts - User bookmarks (dockable left/right, window-shade, default bottom-right) Universal Ebook Reader (Reflowable Formats) β”œβ”€β”€ html-renderer.ts - Browser-native HTML rendering (shared) β”œβ”€β”€ typography-engine.ts - Font rendering, theme integration (shared) β”œβ”€β”€ cfi-navigator.ts - Universal position navigation β”œβ”€β”€ dictionary-popup.ts - Offline dictionary lookup Parser Pipeline β”œβ”€β”€ parser-manager.ts - Routes format β†’ appropriate parser β”œβ”€β”€ cif-types.ts - Common Intermediate Format types β”‚ β”œβ”€β”€ Client-Side Parsers (TypeScript) β”‚ β”œβ”€β”€ epub-parser.ts - EPUB 2/3 parsing (ZIP + XML) β”‚ β”œβ”€β”€ fb2-parser.ts - FictionBook 2 parsing (XML) β”‚ β”œβ”€β”€ txt-parser.ts - Plain text wrapper β”‚ └── html-parser.ts - Standalone HTML files β”‚ └── Server-Side Parsers (Go backend) β”œβ”€β”€ mobi-parser.go - MOBI parsing β”œβ”€β”€ azw3-parser.go - AZW3/KF8 parsing β”œβ”€β”€ docx-parser.go - Word document parsing └── rtf-parser.go - Rich Text Format parsing PDF Reader (Fixed Layout) β”œβ”€β”€ pdfjs-wrapper.ts - Mozilla pdf.js integration β”œβ”€β”€ text-layer-renderer.ts - Text layer overlay for selection β”œβ”€β”€ annotation-layer.ts - Highlight/note rendering β”œβ”€β”€ pdf-navigation.ts - Page navigation, zoom, fit modes β”œβ”€β”€ pdf-search.ts - Full-text search within PDF β”œβ”€β”€ page-cache.ts - 5-page ahead cache β”œβ”€β”€ text-selection.ts - Text selection and highlight creation β”œβ”€β”€ pdf-outline.ts - TOC navigation β”œβ”€β”€ pdf-bookmarks.ts - Custom bookmarks β”œβ”€β”€ pdf-clipboard.ts - Copy to clipboard β”œβ”€β”€ pdf-links.ts - Internal link handling β”œβ”€β”€ pdf-dual-page.ts - Dual page spread view β”œβ”€β”€ pdf-rotation.ts - Rotated page support └── pdf-page-sizes.ts - Variable page size handling Comic Reader (Image Archives) - Uses shared navigator-panel.ts for page navigation β”œβ”€β”€ 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 (when detection ON) └── page-cache.ts - 5-page ahead cache Manga Reader (extends Comic) β”œβ”€β”€ rtl-navigator.ts - Right-to-left navigation β”œβ”€β”€ vertical-scroll-mode.ts - Webtoon-style vertical scroll └── panel-detector.ts - Manga-aware panel detection ``` ### 1.3 Theming Strategy (Hybrid Approach) **Design Decision:** Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ UI Chrome (Bars, Panels, Settings) β”‚ β”‚ βœ… All 11 Bookhoard themes available β”‚ β”‚ - tokyo-night, dracula, nord, etc. β”‚ β”‚ - Maintains consistency with rest of app β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Ebook Text Content β”‚ β”‚ βœ… 5 reading-optimized themes only β”‚ β”‚ - Light (standard) β”‚ β”‚ - Sepia (warm, easier on eyes) β”‚ β”‚ - Dark (reduced eye strain) β”‚ β”‚ - Night (reduced blue light for better sleep) β”‚ β”‚ - High Contrast (accessibility) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ PDF Documents β”‚ β”‚ βœ… 5 reading-optimized themes only β”‚ β”‚ - Light, Sepia, Dark, Night, High Contrast β”‚ β”‚ - PDF.js supports custom CSS for text layer β”‚ β”‚ - Maintains readability for long documents β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Comic/Manga Images β”‚ β”‚ βœ… All 11 Bookhoard themes available β”‚ β”‚ - Visual content works well with any theme β”‚ β”‚ - No eye fatigue concerns with images β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` **Why This Approach?** 1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization 2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes 3. **Accessibility**: Reading-optimized themes help users with visual impairments 4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions 5. **Flexibility**: Still have full theming for UI and visual content **Popular Ebook Reader Comparison:** | Reader | Reading Themes | Color Options? | |-------------|----------------|----------------| | Kindle | 4 | No | | Kobo | 4 | No (green for night) | | Apple Books | 5 | No | | **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** | **Implementation:** - `chrome_theme`: Applied to reader shell, navigation bars, settings panels - `reading_theme`: Applied to ebook text content only (5 options) - Comics/manga: Use `chrome_theme` (all 11 themes work well) ### 1.4 Data Flow ``` User opens reader ↓ Backend: GET /api/readers/:mediaItemId ↓ Verify access, fetch metadata, progress, bookmarks ↓ SSR render: templates/reader.templ with initial data ↓ Frontend: Initialize appropriate reader (Ebook/PDF/Comic/Manga) ↓ Load content (lazy load + cache) ↓ User interacts (turn page, highlight, bookmark) ↓ Real-time sync via WebSocket (reuse existing system) ``` --- ## 2. Database Schema Changes ### 2.1 New Tables ```sql -- Panel detection data CREATE TABLE IF NOT EXISTS panel_data ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, page_number INTEGER NOT NULL, detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual' panels JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(media_item_id, page_number) ); CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id); -- Reading speed tracking CREATE TABLE IF NOT EXISTS reading_speed ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, words_per_minute DECIMAL(6,2), pages_per_minute DECIMAL(6,2), pages_read INTEGER DEFAULT 0, total_reading_minutes DECIMAL(8,2) DEFAULT 0, last_read_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, media_item_id) ); CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id); CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id); -- Dictionary cache (for offline use) CREATE TABLE IF NOT EXISTS dictionary_cache ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), word VARCHAR(100) NOT NULL UNIQUE, definition TEXT NOT NULL, part_of_speech VARCHAR(20), example TEXT, etymology TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), accessed_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word); -- Reader settings (per-user preferences) CREATE TABLE IF NOT EXISTS reader_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, setting_key VARCHAR(50) NOT NULL, setting_value JSONB NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, setting_key) ); CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id); -- PDF bookmarks (custom user bookmarks) CREATE TABLE IF NOT EXISTS media_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, chapter_number INTEGER, cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position title VARCHAR(255) NOT NULL, position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0)) ); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id); ``` ### 2.2 Alter Existing Tables ```sql -- Add chapter metadata to media_items ALTER TABLE media_items ADD COLUMN chapter_metadata JSONB; -- Example structure: -- { -- "chapters": [ -- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20}, -- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25} -- ] -- } -- Note: reading_progress table already exists with epubcfi, page, percentage fields -- Note: notes and highlights tables already exist -- Note: bookmarks table already exists ``` ### 2.3 Schema.sql Implementation **File:** `database/schema/schema.sql` Add the above tables to the schema file. Follow existing patterns: - Use `gen_random_uuid()` for UUID defaults - Use `TIMESTAMPTZ DEFAULT NOW()` for timestamps - Add appropriate indexes for foreign keys - Use `ON DELETE CASCADE` for referential integrity --- ## 2.4 Required Code Implementation (Database & Go Backend) ### Database Tables (Already Exist) The following tables already exist in `database/schema/schema.sql`: - `panel_data` - Panel detection for comics/manga - `reader_settings` - **Stores panel layout configuration** (including dock positions, lock state, collapsed state, width) - `media_bookmarks` - User bookmarks - `reading_speed` - Reading statistics - `dictionary_cache` - Offline dictionary **Panel layout storage**: All panel dock system state (side, visible, collapsed, width_px, order, locked, last_valid_side) is stored as JSON in `reader_settings.setting_value` under the `panel_layout` key. ### Database Schema Issues to Fix The following database schema issues need to be resolved before implementation: 1. **media_items.chapter_metadata**: Add to schema as `JSONB` column (see Section 2.2) 2. **reader_settings table query**: The generated query `UpsertReaderSettings` expects: - Parameters: `UserID` (UUID), `SettingValue` ([]byte) - The query uses `setting_key = 'reader_settings'` hardcoded - Code using `SettingKey` field will fail - remove that field from params 3. **reading_speed table fields**: The generated model uses different types: - `pages_per_minute` is `pgtype.Float8`, not `pgtype.Numeric` - `pages_read` is `pgtype.Int4`, not plain `int32` - Fix type conversions in service code 4. **panel_data table**: The generated model `database.PanelData` is a struct, not a type with `Valid/Bytes` fields - Code using `cached.Valid` and `cached.Bytes` will fail - Need to query directly and handle JSON unmarshaling differently ### Database Queries to Add Add these queries to `internal/database/queries/queries.sql`: ```sql -- name: UpdateMediaItemChapterMetadata :one UPDATE media_items SET chapter_metadata = $2, updated_at = NOW() WHERE id = $1 RETURNING *; -- name: GetReadingSpeedByUser :one SELECT * FROM reading_speed WHERE user_id = $1 AND media_item_id = $2; -- name: UpsertReadingSpeed :one INSERT INTO reading_speed (user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, media_item_id) DO UPDATE SET words_per_minute = COALESCE($3, reading_speed.words_per_minute), pages_per_minute = COALESCE($4, reading_speed.pages_per_minute), pages_read = reading_speed.pages_read + COALESCE($5, 0), total_reading_minutes = reading_speed.total_reading_minutes + COALESCE($6, 0), last_read_at = $7, updated_at = NOW() RETURNING *; ``` ### Go Service Implementation Issues The following issues in `internal/services/reader.go` need fixing: 1. **Line 71-76**: Chapter metadata is `[]byte`, not `pgtype.JSONB` ```go // Change from: if item.ChapterMetadata.Valid { ... item.ChapterMetadata.Bytes ... } // To: if len(item.ChapterMetadata) > 0 { ... item.ChapterMetadata ... } ``` 2. **Line 126**: PageCount is `pgtype.Int4`, use `.Int32` not `.Int64` ```go // Change from: pageCount := int(item.PageCount.Int64) // To: pageCount := int(item.PageCount.Int32) ``` 3. **Lines 172-174**: PanelData query returns `database.PanelData` struct, handle differently 4. **Lines 283-285**: Fix pgtype conversions for reading_speed ```go // Use proper pgtype types: PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true}, PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true}, TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true}, ``` 5. **Lines 353, 386-387**: Fix reader settings access ```go // SettingValue is []byte, use directly if len(settings.SettingValue) > 0 { ... } // UpsertReaderSettingsParams doesn't have SettingKey // Use: UserID, SettingValue only ``` ### Router Implementation The router at `internal/router/reader.go` has a placeholder SSR handler that needs: 1. Fetch metadata/progress/bookmarks (inline or via handler) 2. Import "bookhoard/templates" 3. Call `templates.Reader()` with proper data Note: This creates import cycle if handler imports templates - follow frontend.go pattern where router handles template rendering directly. --- ## 3. API Endpoints ### 3.1 Reader Routes **File:** `internal/router/reader.go` (new file) ```go package router import ( "bookhoard/internal/config" "bookhoard/internal/handlers" "bookhoard/internal/services" ) func registerReaderRoutes(cfg *Config) { e := cfg.Echo // Create reader service and handler readerService := services.NewReaderReaderService(cfg.Queries, cfg.Worker) cfg.ReaderHandler = handlers.NewReaderHandler( cfg.Queries, cfg.LibraryService, readerService, cfg.Worker, ) jwtMiddleware := createJWTMiddleware(cfg) // Reader page routes (SSR + API) reader := e.Group("/readers", jwtMiddleware) // Reader page (SSR) reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader) // Content serving (lazy-loaded pages) reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage) // Chapter metadata reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters) // Panel data (comics/manga) reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels) reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) // PDF outline/TOC reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline) // PDF thumbnails (for mini-map) reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail) // Reading speed reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed) reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed) // Dictionary lookup reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord) // Reader settings reader.GET("/settings", cfg.ReaderHandler.GetSettings) reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) } ``` **File:** `internal/router/router.go` (modify existing) Add ReaderHandler to Config struct (around line 38-67): ```go type Config struct { Echo *echo.Echo Queries *database.Queries Cfg *config.Config DBPool interface{} AuthHandler *handlers.AuthHandler LibraryHandler *handlers.LibraryHandler DeviceHandler *handlers.DeviceHandler MediaHandler *handlers.MediaHandler MatchingHandler *handlers.MatchingHandler KOReaderHandler *handlers.KOReaderHandler WSHandler *handlers.WSHandler ConflictHandler *handlers.ConflictHandler AnalyticsHandler *handlers.AnalyticsHandler QueueHandler *handlers.QueueHandler CollectionHandler *handlers.CollectionHandler Worker *services.Worker FiltersHandler *handlers.FiltersHandler DashboardHandler *handlers.DashboardHandler DashboardService *services.DashboardService OPDSHandler *handlers.OPDSHandler SystemSettingsHandler *handlers.SystemSettingsHandler ConnManager *sync.ConnectionManager QueueProcessor *sync.SyncQueueProcessor DeviceAuthMiddleware *middleware.DeviceAuthMiddleware LoginTracker *ratelimit.LoginAttemptTracker ScannerHandler *handlers.Handler JobsHandler *handlers.JobsHandler SidecarHandler *handlers.SidecarHandler ReaderHandler *handlers.ReaderHandler // ADD THIS LINE } ``` Add registerReaderRoutes call in RegisterRoutes function (after line 220): ```go func RegisterRoutes(cfg *Config) *handlers.Handler { // ... existing route registrations ... registerFiltersRoutes(cfg) registerOPDSRoutes(cfg) registerReaderRoutes(cfg) // ADD THIS LINE - reader routes must be registered before frontend routes registerWebSocketRoutes(cfg) registerFrontendRoutes(cfg) registerDocumentationRoutes(cfg) // ... rest of function ... } ``` ### 3.2 Handler Implementation **File:** `internal/handlers/reader.go` (new file) Follow existing patterns from `media.go` and `auth.go`: - Use `database.Queries` for all DB operations - Return JSON responses with consistent structure - Handle errors properly (404, 403, 500) - Support content negotiation (JSON for API, HTML for SSR) **Complete handler implementation:** ```go package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "bookhoard/templates" "context" "fmt" "io" "net/http" "os" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type ReaderHandler struct { db *database.Queries libraryService *services.LibraryService readerService *services.ReaderService worker *services.Worker } func NewReaderHandler( db *database.Queries, libraryService *services.LibraryService, readerService *services.ReaderService, worker ...*services.Worker, ) *ReaderHandler { rh := &ReaderHandler{ db: db, libraryService: libraryService, readerService: readerService, } if len(worker) > 0 && worker[0] != nil { rh.worker = worker[0] } return rh } // ReaderMetadata contains information needed to render the reader type ReaderMetadata struct { MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` LibraryType string `json:"library_type"` MimeType string `json:"mime_type"` FilePath string `json:"file_path"` TotalPages int `json:"total_pages"` ChapterCount int `json:"chapter_count"` } // ShowReader renders the reader page (SSR) func (h *ReaderHandler) ShowReader(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } // Get media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err != nil { if err == pgx.ErrNoRows { return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"}) } // Get user from context (set by JWT middleware) user := c.Get("user") if user == nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"}) } userData := user.(database.Users) // Check library access hasAccess, err := h.libraryService.UserHasLibraryAccess(c.Request().Context(), userData.ID, mediaItem.LibraryID) if err != nil || !hasAccess { return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"}) } // Get reading progress var progress database.ReadingProgress progress, err = h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, UserID: userData.ID, }) if err != nil && err != pgx.ErrNoRows { progress = database.ReadingProgress{} } // Get bookmarks bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, UserID: userData.ID, }) // Prepare metadata metadata := ReaderMetadata{ MediaItemID: mediaItemID, Title: mediaItem.Title, Author: textToString(mediaItem.Author), CoverImagePath: textToString(mediaItem.CoverImagePath), LibraryType: mediaItem.FormatGroup, MimeType: textToString(mediaItem.MimeType), FilePath: mediaItem.FilePath, TotalPages: int(mediaItem.PageCount), ChapterCount: int(mediaItem.ChapterCount), } // Render template var buf strings.Builder err = templates.Reader(userData, metadata, progress, bookmarks).Render(c.Request().Context(), &buf) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to render reader"}) } return c.HTML(http.StatusOK, buf.String()) } // GetPage returns a specific page for lazy loading func (h *ReaderHandler) GetPage(c echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } page, err := strconv.Atoi(pageNumber) if err != nil || page < 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) } // Get media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) } // Check user access user := c.Get("user").(database.Users) hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID) if !hasAccess { return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"}) } // Resolve full file path fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) } // Get requested format format := c.QueryParam("format") if format == "" { format = "html" } // Extract page content based on format content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "content": content, "page_number": page, "total_pages": mediaItem.PageCount, "media_item_id": mediaItemID, }) } // GetChapters returns chapter metadata func (h *ReaderHandler) GetChapters(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } // Use reader service to detect chapters chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "chapters": chapters, }) } // GetPanels returns panel detection data for comics func (h *ReaderHandler) GetPanels(c echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } page, err := strconv.Atoi(pageNumber) if err != nil || page < 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) } // Get detection method from query method := c.QueryParam("method") if method == "" { method = "grid" } // Use reader service to detect panels panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "page_number": page, "detection_method": method, "panels": panels, }) } // UpdatePanels allows manual panel override func (h *ReaderHandler) UpdatePanels(c echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } page, err := strconv.Atoi(pageNumber) if err != nil || page < 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) } // Parse request body var req struct { Panels []services.Panel `json:"panels"` DetectionMethod string `json:"detection_method"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } // Store manual panel override in database // This would require implementing UpsertPanelData in database/queries.sql _ = parsedUUID _ = page _ = req return c.JSON(http.StatusOK, map[string]interface{}{ "success": true, "message": "Panels updated successfully", }) } // GetReadingSpeed retrieves reading speed statistics func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } user := c.Get("user").(database.Users) // Get reading speed from database speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{ UserID: user.ID, MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, }) if err != nil { if err == pgx.ErrNoRows { // Return zero values if no reading has occurred return c.JSON(http.StatusOK, map[string]interface{}{ "words_per_minute": 0, "pages_per_minute": 0, "pages_read": 0, "total_reading_minutes": 0, "last_read_at": nil, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "words_per_minute": speed.PagesPerMinute.Float64 * 250, // Estimate WPM "pages_per_minute": speed.PagesPerMinute.Float64, "pages_read": speed.PagesRead, "total_reading_minutes": speed.TotalReadingMinutes.Float64, "last_read_at": speed.LastReadAt.Time, }) } // UpdateReadingSpeed updates reading speed statistics func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } user := c.Get("user").(database.Users) // Parse request body var req struct { PagesRead int `json:"pages_read"` TimeSpentMinutes float64 `json:"time_spent_minutes"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } // Update reading speed using service err = h.readerService.CalculateReadingSpeed( c.Request().Context(), user.ID, parsedUUID, req.PagesRead, req.TimeSpentMinutes, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"}) } // Calculate and return updated statistics pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes wordsPerMinute := pagesPerMinute * 250 // Estimate return c.JSON(http.StatusOK, map[string]interface{}{ "success": true, "words_per_minute": wordsPerMinute, "pages_per_minute": pagesPerMinute, }) } // GetPDFOutline returns PDF outline/TOC func (h *ReaderHandler) GetPDFOutline(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } // Get media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) } // Only PDFs have outlines if mediaItem.FormatGroup != "pdf" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"}) } // Extract PDF outline using pdfcpu fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) } // Use pdfcpu to extract outline outline := h.extractPDFOutline(fullPath) return c.JSON(http.StatusOK, map[string]interface{}{ "outline": outline, }) } // GetPDFThumbnail returns a thumbnail for PDF mini-map func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error { mediaItemID := c.Param("mediaItemId") pageNumber := c.Param("pageNumber") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } page, err := strconv.Atoi(pageNumber) if err != nil || page < 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) } // Get media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) } // Get thumbnail size from query width := c.QueryParam("width") height := c.QueryParam("height") // Generate thumbnail using pdfcpu fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) } thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"}) } c.Response().Header().Set("Content-Type", "image/png") return c.Blob(http.StatusOK, "image/png", thumbnail) } // LookupWord performs dictionary lookup func (h *ReaderHandler) LookupWord(c echo.Context) error { word := c.Param("word") if word == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"}) } // Use reader service for dictionary lookup entry, err := h.readerService.LookupWord(c.Request().Context(), word) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"}) } return c.JSON(http.StatusOK, entry) } // GetSettings retrieves user's reader settings func (h *ReaderHandler) GetSettings(c echo.Context) error { user := c.Get("user").(database.Users) // Use reader service to get settings settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"}) } return c.JSON(http.StatusOK, settings) } // UpdateSettings updates user's reader settings func (h *ReaderHandler) UpdateSettings(c echo.Context) error { user := c.Get("user").(database.Users) // Parse request body (partial update supported) var settings map[string]interface{} if err := c.Bind(&settings); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } // Validate settings if readingTheme, ok := settings["reading_theme"].(string); ok { validThemes := map[string]bool{ "light": true, "sepia": true, "dark": true, "night": true, "high-contrast": true, } if !validThemes[readingTheme] { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"}) } } // Use reader service to update settings err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, settings) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) } // Return updated settings updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID) return c.JSON(http.StatusOK, updatedSettings) } // Helper functions func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { // Get library folders folders, err := h.db.GetLibraryFolders(ctx, libraryID) if err != nil { return "", err } // Try each folder until we find the file for _, folder := range folders { fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath if _, err := os.Stat(fullPath); err == nil { return fullPath, nil } } return "", fmt.Errorf("file not found in any library folder") } func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) { // Extract content based on format // This is a simplified implementation // In production, would use format-specific parsers switch item.FormatGroup { case "reflowable": // For EPUB and other ebooks, extract the requested page/chapter return h.extractEbookPage(fullPath, page, format) case "fixed-layout": // For comics, return image data URL or path return h.extractComicPage(fullPath, page, format) case "pdf": // For PDFs, extract text or image return h.extractPDFPage(fullPath, page, format) default: return "", fmt.Errorf("unsupported format: %s", item.FormatGroup) } } func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) { // Simplified EPUB extraction // In production, would use epub-parser.ts logic return fmt.Sprintf("

Page %d content

", page), nil } func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) { // For comics, return image path or data URL return fmt.Sprintf("/api/readers/comic-image?page=%d", page), nil } func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) { // For PDFs, extract text content or image return fmt.Sprintf("

Page %d content

", page), nil } func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} { // Extract PDF outline using pdfcpu // This is a placeholder return []map[string]interface{}{} } func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) { // Generate thumbnail using pdfcpu // This is a placeholder return []byte{}, nil } // Helper function to convert pgtype.Text to string func textToString(t pgtype.Text) string { if !t.Valid { return "" } return t.String } ``` ### 3.3 Service Layer **File:** `internal/services/reader_service.go` (new file) All business logic goes here, not in handlers: ```go package services import ( "bookhoard/internal/database" "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type ReaderService struct { db *database.Queries worker *Worker } func NewReaderService(db *database.Queries, worker *Worker) *ReaderService { return &ReaderService{ db: db, worker: worker, } } // Chapter represents a detected chapter type Chapter struct { ID string `json:"id"` Title string `json:"title"` StartPage int `json:"start_page"` PageCount int `json:"page_count"` Level int `json:"level"` ParentID *string `json:"parent_id,omitempty"` } // Panel represents a detected comic panel type Panel struct { ID string `json:"id"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` ReadingOrder int `json:"reading_order"` } // DictionaryEntry represents a word definition type DictionaryEntry struct { Word string `json:"word"` Definition string `json:"definition"` PartOfSpeech string `json:"part_of_speech,omitempty"` Example string `json:"example,omitempty"` Etymology string `json:"etymology,omitempty"` } // ChapterDetectionResult contains chapter metadata type ChapterDetectionResult struct { Chapters []Chapter `json:"chapters"` Metadata json.RawMessage `json:"metadata,omitempty"` DetectedAt time.Time `json:"detected_at"` } // DetectChapters analyzes a media item to detect chapter structure func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) { // Get media item to determine type item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true}) if err != nil { return nil, fmt.Errorf("failed to get media item: %w", err) } // Check if chapter metadata already exists if item.ChapterMetadata.Valid { var existing ChapterDetectionResult if err := json.Unmarshal(item.ChapterMetadata.Bytes, &existing); err == nil { return existing.Chapters, nil } } // Detect chapters based on format var chapters []Chapter switch item.FormatGroup { case "reflowable": // For ebooks, parse from TOC if available chapters, err = s.detectEbookChapters(ctx, &item) case "fixed-layout": // For comics/manga, detect page breaks as chapters chapters, err = s.detectComicChapters(ctx, &item) case "pdf": // For PDFs, use PDF outline chapters, err = s.detectPDFChapters(ctx, &item) default: chapters = []Chapter{} } if err != nil { return nil, fmt.Errorf("chapter detection failed: %w", err) } // Cache the results result := ChapterDetectionResult{ Chapters: chapters, Metadata: nil, DetectedAt: time.Now(), } metadataBytes, err := json.Marshal(result) if err == nil { // Update media item with chapter metadata // This would require a new query in database/queries.sql _ = metadataBytes } return chapters, nil } func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For EPUB files, parse the TOC from the OPF file // This requires EPUB parsing (see epub-parser.ts) // For now, return empty structure return []Chapter{}, nil } func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For comics, treat each page as a potential chapter // or group pages by story arcs if metadata exists pageCount := int(item.PageCount.Int64) if pageCount <= 0 { return []Chapter{}, nil } chapters := make([]Chapter, 0) chapterSize := 20 // Group pages into chapters of 20 pages each for i := 0; i < pageCount; i += chapterSize { endPage := i + chapterSize if endPage > pageCount { endPage = pageCount } chapters = append(chapters, Chapter{ ID: fmt.Sprintf("chapter-%d", len(chapters)+1), Title: fmt.Sprintf("Page %d-%d", i+1, endPage), StartPage: i + 1, PageCount: endPage - i, Level: 1, }) } return chapters, nil } func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For PDFs, use pdfcpu to extract outline/bookmarks // This requires PDF parsing library return []Chapter{}, nil } // DetectPanels analyzes a comic page to detect panel boundaries func (s *ReaderService) DetectPanels( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string, ) ([]Panel, error) { // Check if panels already exist in cache cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{ MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, PageNumber: int32(pageNumber), }) if err == nil && cached.Valid { var panels []Panel if err := json.Unmarshal(cached.Bytes, &panels); err == nil { return panels, nil } } // Detect panels using specified method var panels []Panel switch method { case "grid": panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) case "ml": panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber) case "manual": panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber) default: // Default to grid detection panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) } if err != nil { return nil, fmt.Errorf("panel detection failed: %w", err) } // Cache the results panelsJSON, _ := json.Marshal(panels) // Insert into panel_data table _ = panelsJSON return panels, nil } func (s *ReaderService) detectPanelsGrid( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // Simple grid-based panel detection // Divide page into 2x2 or 3x3 grid // This is a simplified implementation panels := []Panel{ { ID: "panel-1", X: 0, Y: 0, Width: 50, Height: 100, ReadingOrder: 1, }, { ID: "panel-2", X: 50, Y: 0, Width: 50, Height: 100, ReadingOrder: 2, }, } return panels, nil } func (s *ReaderService) detectPanelsML( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // ML-based panel detection // This would require a trained model // For now, fall back to grid detection return s.detectPanelsGrid(ctx, mediaItemID, pageNumber) } func (s *ReaderService) detectPanelsManual( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // Manual panel detection returns existing manually-set panels // These would be stored in the panel_data table return []Panel{}, nil } // CalculateReadingSpeed updates reading speed statistics func (s *ReaderService) CalculateReadingSpeed( ctx context.Context, userID uuid.UUID, mediaItemID uuid.UUID, pagesRead int, minutes float64, ) error { if minutes <= 0 { return fmt.Errorf("invalid time: must be positive") } pagesPerMinute := float64(pagesRead) / minutes // Get or create reading speed record _, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, }) if err != nil { // Create new record _, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true}, PagesRead: int32(pagesRead), TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true}, LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, }) } else { // Update existing record with moving average // This would require an UpdateReadingSpeed query _ = pagesPerMinute } return err } // LookupWord retrieves dictionary entry for a word func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) { // Check cache first cached, err := s.db.GetDictionaryEntry(ctx, word) if err == nil { return &DictionaryEntry{ Word: cached.Word, Definition: cached.Definition, PartOfSpeech: cached.PartOfSpeech.String, Example: cached.Example.String, Etymology: cached.Etymology.String, }, nil } // Not in cache, fetch from dictionary API entry, err := s.fetchDictionaryEntry(ctx, word) if err != nil { return nil, err } // Cache the entry _, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{ Word: entry.Word, Definition: entry.Definition, PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""}, Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""}, Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""}, }) return entry, nil } func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) { // Fetch from external dictionary API // For now, return a placeholder return &DictionaryEntry{ Word: word, Definition: fmt.Sprintf("Definition for %s", word), }, nil } // GetSettings retrieves reader settings for a user func (s *ReaderService) GetSettings( ctx context.Context, userID uuid.UUID, ) (map[string]interface{}, error) { // Get settings from database settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { // Return default settings return s.getDefaultSettings(), nil } var result map[string]interface{} if err := json.Unmarshal(settings.Bytes, &result); err != nil { return s.getDefaultSettings(), nil } return result, nil } // UpdateSettings updates reader settings for a user func (s *ReaderService) UpdateSettings( ctx context.Context, userID uuid.UUID, settings map[string]interface{}, ) error { // Merge with existing settings existing, err := s.GetSettings(ctx, userID) if err != nil { existing = s.getDefaultSettings() } // Merge settings (partial update) for key, value := range settings { existing[key] = value } // Serialize and save settingsJSON, err := json.Marshal(existing) if err != nil { return fmt.Errorf("failed to serialize settings: %w", err) } // Update in database _, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, SettingKey: "reader_settings", SettingValue: string(settingsJSON), }) return err } func (s *ReaderService) getDefaultSettings() map[string]interface{} { return map[string]interface{}{ "chrome_behavior": "auto-hide", "progress_mode": "pages", "chrome_theme": "tokyo-night", "reading_theme": "dark", "reading_font": "literata", "font_size": 16, "line_height": 1.6, "margin_width": 20, "tap_zone_size": 30, "auto_scroll": false, "panel_zoom_enabled": true, } } ``` --- ## 4. Frontend Implementation ### 4.1 File Structure ``` web/src/reader/ β”œβ”€β”€ reader.ts - Main reader entry point β”œβ”€β”€ reader-shell.ts - UI shell, chrome control β”œβ”€β”€ progress-indicator.ts - KOReader-style switchable progress β”œβ”€β”€ settings-manager.ts - Settings (DB + localStorage) β”œβ”€β”€ slide-in-panel.ts - Shared slide-in panel (TOC + Settings) β”œβ”€β”€ annotation-manager.ts - Highlights, notes, bookmarks β”œβ”€β”€ websocket-sync.ts - Reuse existing websocket.ts β”œβ”€β”€ dictionary-popup.ts - Offline dictionary lookup β”‚ β”œβ”€β”€ ebook/ β”‚ β”œβ”€β”€ epub-parser.ts - EPUB parsing (ZIP + XML) β”‚ β”œβ”€β”€ html-renderer.ts - Browser-native rendering β”‚ β”œβ”€β”€ cfi-navigator.ts - EPUB CFI navigation β”‚ β”œβ”€β”€ typography-engine.ts - Font rendering, themes β”‚ └── chapter-detector.ts - Chapter detection β”‚ β”œβ”€β”€ comic/ β”‚ β”œβ”€β”€ image-parser.ts - CBZ/CBR/PDF parsing β”‚ β”œβ”€β”€ canvas-renderer.ts - Canvas rendering β”‚ β”œβ”€β”€ panel-detector.ts - Grid + ML + manual β”‚ β”œβ”€β”€ panel-navigator.ts - Panel zoom animations β”‚ └── page-cache.ts - 5-page ahead cache β”‚ └── manga/ β”œβ”€β”€ rtl-navigator.ts - Right-to-left navigation └── vertical-scroll.ts - Webtoon-style scroll ``` ### 4.2 TypeScript Types **File:** `web/src/types/reader.d.ts` (new file) ```typescript // ============================================================ // Common Intermediate Format (CIF) Types // Universal format for all reflowable ebooks after parsing // ============================================================ interface EbookCIF { // Universal metadata (all formats) metadata: { title: string; author: string; language: string; publisher?: string; isbn?: string; coverImage?: Blob; }; // Unified navigation structure toc: TOCNode[]; // Content spine (reading order) spine: SpineItem[]; // Resources (CSS, fonts, images) resources: Map; // Progress tracking (minimal - backend handles detailed tracking) locations: { totalCharacters: number; estimatedPages: number; }; } interface SpineItem { id: string; type: 'html' | 'image'; content: string; properties?: string[]; // Minimal position info for UI index: number; } interface TOCNode { id: string; title: string; href: string; children: TOCNode[]; } // ============================================================ // Parser Types (Procedural, not OOP) // ============================================================ type ParserFormat = 'epub' | 'fb2' | 'txt' | 'html' | 'mobi' | 'azw3' | 'docx' | 'rtf'; interface ParserCapabilities { canParse(mimeType: string, extension: string): boolean; parse(file: Blob): Promise; extractMetadata(file: Blob): Promise>; } // ============================================================ // Reader Metadata (from API) // ============================================================ interface ReaderMetadata { media_item_id: string; title: string; author: string; cover_image_path: string; library_type: 'ebook' | 'comic' | 'manga' | 'pdf'; mime_type: string; file_path: string; chapter_metadata?: ChapterMetadata; total_pages?: number; } // ============================================================ // Other Shared Types // ============================================================ interface ChapterMetadata { chapters: Chapter[]; } interface Chapter { id: string; title: string; start_page: number; page_count: number; } interface PanelData { media_item_id: string; page_number: number; detection_method: 'grid' | 'ml' | 'manual'; panels: Panel[]; updated_at: string; } interface Panel { id: string; x: number; y: number; width: number; height: number; reading_order: number; } interface ReadingSpeed { words_per_minute: number; pages_per_minute: number; pages_read: number; total_reading_minutes: number; last_read_at: string; } interface DictionaryEntry { word: string; definition: string; part_of_speech?: string; example?: string; etymology?: string; } interface ReaderSettings { chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll'; progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; chrome_theme: string; reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; font_size: number; line_height: number; margin_width: number; tap_zone_size: number; auto_scroll: boolean; panel_zoom_enabled: boolean; double_page_spread: boolean; reading_direction: 'ltr' | 'rtl' | 'vertical'; pdf_fit_mode: 'fit-width' | 'fit-page' | 'fit-height' | 'none'; pdf_zoom_level: number; pdf_text_layer_enabled: boolean; pdf_dual_page_mode: 'auto' | 'single' | 'dual'; pdf_dual_page_threshold: number; pdf_minimap_enabled: boolean; pdf_outline_visible: boolean; media_bookmarks_visible: boolean; hardware_acceleration: boolean; // Dockable panel layout configuration (per-user, media-type-aware) panel_layout: PanelLayoutSettings; } interface PanelLayoutSettings { // Per panel state: which side, visible, collapsed (window-shade), width toc: PanelState; settings: PanelState; navigator: PanelState; bookmarks: PanelState; // Mobile override mobile_nav_visible: boolean; } interface PanelState { side: 'left' | 'right' | 'hidden'; visible: boolean; collapsed: boolean; // Window-shade: true = collapsed to title bar width_px: number; // Panel width in pixels order: number; // Order within the side locked: boolean; // Lock toggle: prevents accidental drag/move last_valid_side: 'left' | 'right' | 'hidden'; // Snap-back target if dropped in invalid area } interface ProgressDisplay { mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; current: number; total: number; label?: string; time_left?: string; } ``` ### 4.3 Universal Reader Shell (Procedural) **File:** `web/src/reader/reader-shell.ts` ```typescript // Universal Reader Shell - Routes to appropriate reader // Procedural style: Functions, not classes import { Alpine } from "../alpine"; import { getReaderMetadata, updateReadingProgress } from "./api"; import { SettingsManager } from "./settings-manager"; import { ProgressIndicator } from "./progress-indicator"; import { parseEbook, requiresServerParsing } from './parser-manager'; import { initializePDFReader } from './pdf/pdfjs-wrapper'; import { initializeComicReader } from './comic/image-parser'; // ============================================================ // Reader State // ============================================================ let currentReader: UniversalReader | PDFReader | ComicReader | MangaReader | null = null; let readerMetadata: ReaderMetadata | null = null; interface UniversalReader { type: 'ebook'; cif: EbookCIF; currentSpineIndex: number; } interface PDFReader { type: 'pdf'; doc: any; currentPage: number; } interface ComicReader { type: 'comic'; images: Blob[]; currentPage: number; } interface MangaReader { type: 'manga'; images: Blob[]; currentPage: number; readingDirection: 'rtl' | 'vertical'; } // ============================================================ // Initialization // ============================================================ async function initializeReader(): Promise { const mediaItemId = document.body.dataset.mediaItemId; if (!mediaItemId) return; // Fetch metadata readerMetadata = await getReaderMetadata(mediaItemId); // Initialize appropriate reader based on type switch (readerMetadata.library_type) { case 'ebook': currentReader = await initializeEbookReader(readerMetadata); break; case 'pdf': currentReader = await initializePDFReader(readerMetadata); break; case 'comic': currentReader = await initializeComicReader(readerMetadata); break; case 'manga': currentReader = await initializeMangaReader(readerMetadata); break; } if (currentReader) { setupReaderUI(); } } async function initializeEbookReader(metadata: ReaderMetadata): Promise { // Check if server-side parsing is needed const needsServer = requiresServerParsing(metadata.mime_type, getFileExtension(metadata.file_path)); let ebookFile: Blob; if (needsServer) { // Fetch parsed CIF from server const response = await fetch(`/api/readers/${metadata.media_item_id}/parse`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mime_type: metadata.mime_type, file_path: metadata.file_path, }), }); if (!response.ok) { throw new Error(`Server parsing failed: ${response.statusText}`); } ebookFile = await response.blob(); } else { // Fetch original file for client-side parsing const response = await fetch(metadata.file_path); ebookFile = await response.blob(); } // Parse ebook to CIF const cif = await parseEbook(ebookFile, metadata.mime_type, getFileExtension(metadata.file_path)); return { type: 'ebook', cif, currentSpineIndex: 0, }; } // ============================================================ // UI Setup // ============================================================ function setupReaderUI(): void { if (!currentReader || !readerMetadata) return; // Setup chrome setupChromeBehavior(); // Setup progress indicator setupProgressIndicator(); // Setup annotations setupAnnotations(); // Setup keyboard navigation setupKeyboardNavigation(); } function setupChromeBehavior(): void { const chrome = document.getElementById('reader-chrome'); if (!chrome) return; // Auto-hide on scroll let hideTimeout: NodeJS.Timeout; window.addEventListener('scroll', () => { chrome.classList.add('visible'); clearTimeout(hideTimeout); hideTimeout = setTimeout(() => { chrome.classList.remove('visible'); }, 2000); }); // Toggle on tap (for touch devices) chrome.addEventListener('click', () => { chrome.classList.toggle('visible'); }); } function setupProgressIndicator(): void { // Update progress based on reader type if (!currentReader) return; if (currentReader.type === 'ebook') { updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex); } else if (currentReader.type === 'pdf') { updatePDFProgress(currentReader.currentPage, readerMetadata.total_pages || 0); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { updateComicProgress(currentReader.currentPage, currentReader.images.length); } } function setupAnnotations(): void { // Load existing highlights and notes // Implementation depends on annotation system } function setupKeyboardNavigation(): void { document.addEventListener('keydown', (e) => { if (!currentReader) return; switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); nextPage(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); previousPage(); break; } }); } // ============================================================ // Navigation Functions // ============================================================ function nextPage(): void { if (!currentReader) return; if (currentReader.type === 'ebook') { nextSpineItem(); } else if (currentReader.type === 'pdf') { nextPDFPage(); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { nextComicPage(); } } function previousPage(): void { if (!currentReader) return; if (currentReader.type === 'ebook') { previousSpineItem(); } else if (currentReader.type === 'pdf') { previousPDFPage(); } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { previousComicPage(); } } function nextSpineItem(): void { if (currentReader?.type !== 'ebook') return; if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) { currentReader.currentSpineIndex++; renderCurrentSpineItem(); } } function previousSpineItem(): void { if (currentReader?.type !== 'ebook') return; if (currentReader.currentSpineIndex > 0) { currentReader.currentSpineIndex--; renderCurrentSpineItem(); } } function renderCurrentSpineItem(): void { if (currentReader?.type !== 'ebook') return; const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex]; const container = document.getElementById('reader-content'); if (!container) return; // Render spine item content container.innerHTML = spineItem.content; // Apply theme and typography applyReaderTheme(); applyTypography(); // Update progress updateProgress(); } // ============================================================ // Progress Tracking // ============================================================ function updateProgress(): void { if (!currentReader || !readerMetadata) return; let percentage = 0; let currentPosition = ''; if (currentReader.type === 'ebook') { const totalSpine = currentReader.cif.spine.length; percentage = (currentReader.currentSpineIndex + 1) / totalSpine; currentPosition = `spine:${currentReader.currentSpineIndex}`; } else if (currentReader.type === 'pdf') { const totalPages = readerMetadata.total_pages || 1; percentage = currentReader.currentPage / totalPages; currentPosition = `page:${currentReader.currentPage}`; } else if (currentReader.type === 'comic' || currentReader.type === 'manga') { const totalPages = currentReader.images.length; percentage = currentReader.currentPage / totalPages; currentPosition = `page:${currentReader.currentPage}`; } // Send to backend updateReadingProgress(readerMetadata.media_item_id, { percentage, current_page: currentReader.type === 'ebook' ? currentReader.currentSpineIndex : currentReader.currentPage, position: currentPosition, }); } // ============================================================ // Alpine.js Integration // ============================================================ Alpine.data('readerShell', () => ({ init() { initializeReader(); }, nextPage, previousPage, get currentPage() { if (!currentReader) return 0; if (currentReader.type === 'ebook') { return currentReader.currentSpineIndex + 1; } else { return currentReader.currentPage; } }, get totalPages() { if (!currentReader || !readerMetadata) return 0; if (currentReader.type === 'ebook') { return currentReader.cif.spine.length; } else if (currentReader.type === 'pdf') { return readerMetadata.total_pages || 0; } else { return currentReader.images.length; } }, })); // ============================================================ // Utility Functions // ============================================================ function getFileExtension(filepath: string): string { const match = filepath.match(/\.([^.]+)$/); return match ? `.${match[1]}` : ''; } function applyReaderTheme(): void { // Apply reading theme from settings const settings = getReaderSettings(); const container = document.getElementById('reader-content'); if (!container) return; container.className = `ebook-content theme-${settings.reading_theme}`; } function applyTypography(): void { const settings = getReaderSettings(); const container = document.getElementById('reader-content'); if (!container) return; container.style.fontSize = `${settings.font_size}px`; container.style.lineHeight = settings.line_height.toString(); container.style.fontFamily = getFontStack(settings.reading_font); } function getFontStack(font: string): string { const stacks: Record = { 'literata': '"Literata", serif', 'crimson': '"Crimson Text", serif', 'source-serif': '"Source Serif 4", serif', 'eb-garamond': '"EB Garamond", serif', 'libertinus': '"Libertinus Serif", serif', 'noto-serif': '"Noto Serif", serif', 'charis-sil': '"Charis SIL", serif', 'ibm-plex': '"IBM Plex Serif", serif', }; return stacks[font] || stacks['literata']; } function getReaderSettings(): ReaderSettings { // Load from settings manager return {} as ReaderSettings; // Simplified } ``` --- ### 4.4 Server-Side Parsers (Go Backend) **File:** `internal/handlers/reader.go` (new file) ```go package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "github.com/labstack/echo/v5" "github.com/google/uuid" ) type ReaderHandler struct { db *database.Queries libraryService *services.LibraryService } func NewReaderHandler(db *database.Queries, libraryService *services.LibraryService) *ReaderHandler { return &ReaderHandler{ db: db, libraryService: libraryService, } } // ParseEbook parses complex ebook formats on the server func (h *ReaderHandler) ParseEbook(c echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(400, map[string]string{"error": "Invalid media item ID"}) } // Fetch media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), parsedUUID) if err != nil { return c.JSON(404, map[string]string{"error": "Media item not found"}) } // Route to appropriate parser based on format var cif interface{} switch mediaItem.MimeType.String { case "application/x-mobipocket-ebook": cif, err = h.parseMOBI(c.Request().Context(), mediaItem.FilePath) case "application/vnd.amazon.mobi8-ebook": cif, err = h.parseAZW3(c.Request().Context(), mediaItem.FilePath) case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": cif, err = h.parseDOCX(c.Request().Context(), mediaItem.FilePath) case "application/rtf": cif, err = h.parseRTF(c.Request().Context(), mediaItem.FilePath) default: return c.JSON(400, map[string]string{"error": "Unsupported format for server-side parsing"}) } if err != nil { return c.JSON(500, map[string]string{"error": "Parsing failed: " + err.Error()}) } return c.JSON(200, cif) } // Server-side parser implementations func (h *ReaderHandler) parseMOBI(ctx context.Context, filePath string) (interface{}, error) { // MOBI format parsing implementation // MOBI is a binary format - extract text and basic structure // Returns CIF structure for client-side rendering fileData, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read MOBI file: %w", err) } // Parse MOBI header and extract content // MOBI files start with "BOOKMOBI" or "TDMOBI" if len(fileData) < 8 || string(fileData[0x3C:0x3C+8]) != "BOOKMOBI" { return nil, fmt.Errorf("invalid MOBI file format") } // Extract metadata and content // This is a simplified implementation - full MOBI parsing is complex // For production, use a dedicated MOBI parsing library metadata := map[string]interface{}{ "title": extractMOBIMetadata(fileData, "title"), "author": extractMOBIMetadata(fileData, "author"), "format": "mobi", "is_reflowable": true, } // Extract text content (simplified) content := extractMOBIText(fileData) return map[string]interface{}{ "metadata": metadata, "spine": []map[string]interface{}{ { "id": "mobi-content", "type": "html", "content": content, }, }, "toc": []map[string]interface{}{}, "resources": map[string]interface{}{}, }, nil } func (h *ReaderHandler) parseAZW3(ctx context.Context, filePath string) (interface{}, error) { // AZW3/KF8 format parsing // AZW3 is similar to EPUB but with Amazon-specific DRM and structure // For now, we'll treat it as a ZIP file and extract EPUB-like content // Open AZW3 file (which is a ZIP archive) zipReader, err := zip.OpenReader(filePath) if err != nil { return nil, fmt.Errorf("failed to open AZW3 file: %w", err) } defer zipReader.Close() // Find and parse the content.opf file var opfFile *zip.File for _, f := range zipReader.File { if strings.HasSuffix(f.Name, ".opf") { opfFile = f break } } if opfFile == nil { return nil, fmt.Errorf("no OPF file found in AZW3 archive") } opfReader, err := opfFile.Open() if err != nil { return nil, fmt.Errorf("failed to open OPF file: %w", err) } defer opfReader.Close() opfData, err := io.ReadAll(opfReader) if err != nil { return nil, fmt.Errorf("failed to read OPF file: %w", err) } // Parse OPF XML to extract metadata and spine // This follows EPUB parsing pattern from epub-parser.ts metadata, spine, toc := parseOPFXML(opfData) // Extract HTML content files resources := make(map[string]string) for _, f := range zipReader.File { if strings.HasSuffix(f.Name, ".html") || strings.HasSuffix(f.Name, ".htm") { reader, err := f.Open() if err != nil { continue } content, _ := io.ReadAll(reader) reader.Close() resources[f.Name] = string(content) } } return map[string]interface{}{ "metadata": metadata, "spine": spine, "toc": toc, "resources": resources, }, nil } func (h *ReaderHandler) parseDOCX(ctx context.Context, filePath string) (interface{}, error) { // DOCX format parsing // DOCX is a ZIP archive containing XML files // Main content is in word/document.xml // Open DOCX file (ZIP archive) zipReader, err := zip.OpenReader(filePath) if err != nil { return nil, fmt.Errorf("failed to open DOCX file: %w", err) } defer zipReader.Close() // Find and parse word/document.xml docXmlPath := "word/document.xml" docFile, err := openFileFromZip(zipReader, docXmlPath) if err != nil { return nil, fmt.Errorf("failed to open document.xml: %w", err) } defer docFile.Close() // Parse document structure // DOCX XML structure: text docData, err := io.ReadAll(docFile) if err != nil { return nil, fmt.Errorf("failed to read document.xml: %w", err) } // Extract text content and paragraphs paragraphs := extractDOCXParagraphs(docData) // Extract metadata from docProps/core.xml or docProps/app.xml metadata := extractDOCXMetadata(zipReader) // Convert paragraphs to HTML htmlContent := convertDOCXToHTML(paragraphs) return map[string]interface{}{ "metadata": metadata, "spine": []map[string]interface{}{ { "id": "docx-content", "type": "html", "content": htmlContent, }, }, "toc": []map[string]interface{}{}, "resources": map[string]interface{}{}, }, nil } func (h *ReaderHandler) parseRTF(ctx context.Context, filePath string) (interface{}, error) { // RTF (Rich Text Format) parsing // RTF is a text-based format with control codes // Format: {\rtf1\ansi{\fonttbl...}{\colortbl...}\pard Text \par} fileData, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read RTF file: %w", err) } // Validate RTF header if !bytes.HasPrefix(fileData, []byte("{\\rtf")) { return nil, fmt.Errorf("invalid RTF file format") } // Parse RTF control codes and extract text // RTF uses backslash commands: \par = paragraph, \b = bold, \i = italic, etc. textContent, formatting := parseRTFText(fileData) // Convert RTF formatting to HTML htmlContent := convertRTFToHTML(textContent, formatting) // Extract metadata from RTF info group metadata := extractRTFMetadata(fileData) return map[string]interface{}{ "metadata": metadata, "spine": []map[string]interface{}{ { "id": "rtf-content", "type": "html", "content": htmlContent, }, }, "toc": []map[string]interface{}{}, "resources": map[string]interface{}{}, }, nil } // Helper functions for MOBI parsing func extractMOBIMetadata(data []byte, field string) string { // Extract metadata from MOBI header // This is a simplified implementation // Full implementation would parse MOBI EXTH headers return "" } func extractMOBIText(data []byte) string { // Extract text content from MOBI file // MOBI text is typically compressed/huffman encoded // For now, return placeholder return "

MOBI content extraction requires full parser implementation

" } // Helper functions for AZW3/EPUB parsing func parseOPFXML(data []byte) (map[string]interface{}, []map[string]interface{}, []map[string]interface{}) { // Parse OPF XML to extract metadata, spine, and TOC // Follows the pattern from epub-parser.ts but in Go metadata := make(map[string]interface{}) spine := []map[string]interface{}{} toc := []map[string]interface{}{} // Parse XML and extract elements // This uses Go's encoding/xml package decoder := xml.NewDecoder(bytes.NewReader(data)) // Implementation details would parse: // - section for title, author, etc. // - for resource list // - for reading order // - for TOC return metadata, spine, toc } // Helper functions for DOCX parsing func openFileFromZip(zipReader *zip.ReadCloser, path string) (io.ReadCloser, error) { for _, f := range zipReader.File { if f.Name == path { return f.Open() } } return nil, fmt.Errorf("file not found in archive: %s", path) } func extractDOCXParagraphs(docData []byte) []map[string]interface{} { // Parse DOCX XML and extract paragraphs // Returns array of paragraph objects with text and formatting paragraphs := []map[string]interface{}{} // Parse XML structure // elements contain paragraphs // elements contain runs // elements contain text // Simplified implementation - would use xml.Unmarshal paragraphs = append(paragraphs, map[string]interface{}{ "text": "Extracted DOCX content", "bold": false, "italic": false, "underline": false, }) return paragraphs } func extractDOCXMetadata(zipReader *zip.ReadCloser) map[string]interface{} { metadata := make(map[string]interface{}) // Try to read docProps/core.xml coreXmlPath := "docProps/core.xml" if coreXmlFile, err := openFileFromZip(zipReader, coreXmlPath); err == nil { defer coreXmlFile.Close() coreData, _ := io.ReadAll(coreXmlFile) // Parse Dublin Core metadata // , , , etc. _ = coreData } metadata["title"] = "Document Title" metadata["author"] = "Unknown Author" metadata["format"] = "docx" metadata["is_reflowable"] = true return metadata } func convertDOCXToHTML(paragraphs []map[string]interface{}) string { var html strings.Builder html.WriteString("") for _, p := range paragraphs { text, _ := p["text"].(string) bold, _ := p["bold"].(bool) italic, _ := p["italic"].(bool) html.WriteString("") html.WriteString(text) html.WriteString("

") } html.WriteString("") return html.String() } // Helper functions for RTF parsing func parseRTFText(data []byte) (string, map[string]interface{}) { // Parse RTF control codes and extract plain text // RTF format: {\rtf1\ansi{\fonttbl...}\pard Text \par} // Remove control codes and extract text text := strings.Builder{} formatting := make(map[string]interface{}) // Skip RTF header idx := 0 for idx < len(data) { if data[idx] == '\\' { // Parse control word end := idx + 1 for end < len(data) && data[end] != ' ' && data[end] != '\\' && data[end] != '}' { end++ } control := string(data[idx+1 : end]) // Handle common control words switch control { case "par": text.WriteString("
") case "tab": text.WriteString("    ") case "b": formatting["bold"] = true case "b0": formatting["bold"] = false case "i": formatting["italic"] = true case "i0": formatting["italic"] = false } idx = end } else if data[idx] == '{' || data[idx] == '}' { // Group delimiters - skip idx++ } else if data[idx] >= 32 && data[idx] <= 126 { // Printable ASCII text.WriteByte(data[idx]) idx++ } else { // Skip other characters idx++ } } return text.String(), formatting } func convertRTFToHTML(text string, formatting map[string]interface{}) string { var html strings.Builder html.WriteString("

") html.WriteString(text) html.WriteString("

") return html.String() } func extractRTFMetadata(data []byte) map[string]interface{} { metadata := make(map[string]interface{}) // RTF metadata is in {\info {...}} group // Look for {\title ...}, {\author ...}, etc. metadata["title"] = "RTF Document" metadata["author"] = "Unknown" metadata["format"] = "rtf" metadata["is_reflowable"] = true return metadata } ``` **File:** `web/src/reader/reader-shell.ts` ```typescript // Shared reader infrastructure // Implements chrome control, routing, settings sync import { Alpine } from "../alpine"; import { getReaderMetadata, updateReadingProgress } from "./api"; import { SettingsManager } from "./settings-manager"; import { ProgressIndicator } from "./progress-indicator"; let currentReader: EbookReader | PDFReader | ComicReader | MangaReader | null = null; function initializeReader(): void { const mediaItemId = document.body.dataset.mediaItemId; if (!mediaItemId) return; // Fetch metadata getReaderMetadata(mediaItemId).then((metadata) => { // Initialize appropriate reader based on type switch (metadata.library_type) { case 'ebook': currentReader = new EbookReader(metadata); break; case 'pdf': currentReader = new PDFReader(metadata); break; case 'comic': currentReader = new ComicReader(metadata); break; case 'manga': currentReader = new MangaReader(metadata); break; } currentReader?.initialize(); }); } // Chrome control function toggleChrome(): void { const chrome = document.getElementById('reader-chrome'); chrome?.classList.toggle('hidden'); } function setChromeBehavior(behavior: ReaderSettings['chrome_behavior']): void { // Auto-hide, always-visible, or hide-on-scroll } // Export for Alpine integration Alpine.data('readerShell', () => ({ init() { initializeReader(); } })); ``` ### 4.5 Progress Indicator (KOReader-style) **File:** `web/src/reader/progress-indicator.ts` ```typescript // KOReader-style switchable progress indicator import { Alpine } from "../alpine"; import { getReadingSpeed } from "./api"; interface ProgressDisplay { mode: 'pages' | 'chapter' | 'percentage' | 'time-left'; text: string; } function calculateProgress( currentPage: number, totalPages: number, currentChapterPage: number, chapterPages: number, readingSpeed?: ReadingSpeed ): ProgressDisplay { const mode = getCurrentProgressMode(); // From settings switch (mode) { case 'pages': return { mode: 'pages', text: `${currentPage}/${totalPages}` }; case 'chapter': return { mode: 'chapter', text: `${currentChapterPage}/${chapterPages}` }; case 'percentage': const percentage = Math.round((currentPage / totalPages) * 100); return { mode: 'percentage', text: `${percentage}%` }; case 'time-left': if (!readingSpeed) { return { mode: 'time-left', text: '--:--' }; } const pagesLeft = totalPages - currentPage; const minutesLeft = pagesLeft / readingSpeed.pages_per_minute; const hours = Math.floor(minutesLeft / 60); const mins = Math.round(minutesLeft % 60); return { mode: 'time-left', text: `${hours}h ${mins}m` }; } } function cycleProgressMode(): void { const modes: Array<'pages' | 'chapter' | 'percentage' | 'time-left'> = ['pages', 'chapter', 'percentage', 'time-left']; const currentMode = getCurrentProgressMode(); const currentIndex = modes.indexOf(currentMode); const nextMode = modes[(currentIndex + 1) % modes.length]; setProgressMode(nextMode); } ``` ### 4.6 Settings Manager (DB + localStorage) **File:** `web/src/reader/settings-manager.ts` ```typescript // Per-user settings with localStorage fallback import { apiGet, apiPut } from "../api"; import { getToken, setItem, getItem } from "../storage"; const SETTINGS_KEY = 'reader_settings'; const LOCALSTORAGE_KEY = 'reader_settings_local'; interface SettingsManager { load(): Promise; save(settings: Partial): Promise; sync(): Promise; // Sync localStorage β†’ DB get(key: keyof ReaderSettings): any; set(key: keyof ReaderSettings, value: any): Promise; } async function loadSettings(): Promise { const token = getToken(); if (!token) { // Fallback to localStorage const local = getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } try { const response = await apiGet('/readers/settings'); const settings = await response.json(); // Cache in localStorage setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); return settings; } catch (error) { // Fallback to localStorage on error const local = getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } } async function saveSettings(settings: Partial): Promise { const token = getToken(); if (!token) { // Save to localStorage only const current = loadSettings(); const updated = { ...current, ...settings }; setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); return; } try { await apiPut('/readers/settings', settings); // Update localStorage cache const current = loadSettings(); const updated = { ...current, ...settings }; setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); } catch (error) { // Fallback to localStorage const current = loadSettings(); const updated = { ...current, ...settings }; setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); } } function getDefaultSettings(): ReaderSettings { return { chrome_behavior: 'auto-hide', progress_mode: 'pages', chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available reading_theme: 'dark', // Ebook text: 5 reading-optimized themes reading_font: 'literata', // Default reading font (designed for ebooks) tap_zone_size: 30, auto_scroll: false, panel_zoom_enabled: true, font_size: 16, line_height: 1.6, margin_width: 20, double_page_spread: false, reading_direction: 'ltr', hardware_acceleration: true, // Dockable panel defaults by media type panel_layout: { toc: { side: 'left', visible: true, collapsed: false, width_px: 320, order: 1, locked: false, last_valid_side: 'left' }, settings: { side: 'left', visible: false, collapsed: true, width_px: 380, order: 2, locked: false, last_valid_side: 'left' }, navigator: { side: 'right', visible: true, collapsed: false, width_px: 200, order: 1, locked: false, last_valid_side: 'right' }, bookmarks: { side: 'right', visible: false, collapsed: true, width_px: 280, order: 2, locked: false, last_valid_side: 'right' }, mobile_nav_visible: false } }; } ``` ### 4.7 Panel Dock System (Modular Dockable Panels) **File:** `web/src/reader/panel-dock-system.ts` ```typescript // Modular dockable panel system - handles drag, lock, snap-back, window-shade // Procedural style: Functions, not classes import { saveSettings, loadSettings, getDefaultSettings } from "./settings-manager"; interface PanelDockState { panels: Map; dragState: DragState | null; dockZones: DockZone[]; } interface DragState { panelId: string; startX: number; startY: number; currentX: number; currentY: number; isLocked: boolean; } interface DockZone { side: 'left' | 'right'; x: number; width: number; height: number; } const state: PanelDockState = { panels: new Map(), dragState: null, dockZones: [ { side: 'left', x: 0, width: 400, height: window.innerHeight }, { side: 'right', x: window.innerWidth - 400, width: 400, height: window.innerHeight } ] }; // Initialize all panels from settings function initializePanelDockSystem(): void { const settings = loadSettings(); for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { registerPanel(panelId, panelState); } setupDragHandlers(); setupWindowShadeHandlers(); setupLockHandlers(); } // Register a panel with the dock system function registerPanel(panelId: string, panelState: PanelState): void { state.panels.set(panelId, panelState); applyPanelState(panelId, panelState); } // Apply panel state to DOM function applyPanelState(panelId: string, panelState: PanelState): void { const panel = document.querySelector(`[data-panel="${panelId}"]`); if (!panel) return; const container = panel.parentElement; if (!container) return; // Apply side positioning if (panelState.side === 'left') { container.style.left = '0'; container.style.right = 'auto'; } else if (panelState.side === 'right') { container.style.right = '0'; container.style.left = 'auto'; } else { container.style.left = '-9999px'; } // Apply width panel.style.width = `${panelState.width_px}px`; // Apply collapsed (window-shade) state if (panelState.collapsed) { panel.classList.add('panel-collapsed'); panel.querySelector('.panel-content')?.classList.add('hidden'); } else { panel.classList.remove('panel-collapsed'); panel.querySelector('.panel-content')?.classList.remove('hidden'); } // Apply lock state const lockBtn = panel.querySelector('.panel-lock'); if (lockBtn) { lockBtn.textContent = panelState.locked ? 'πŸ”’' : 'πŸ”“'; } } // Setup mouse/touch drag handlers function setupDragHandlers(): void { document.querySelectorAll('.dockable-panel .panel-header').forEach(header => { header.addEventListener('mousedown', handleDragStart); header.addEventListener('touchstart', handleDragStart, { passive: false }); }); document.addEventListener('mousemove', handleDragMove); document.addEventListener('touchmove', handleDragMove, { passive: false }); document.addEventListener('mouseup', handleDragEnd); document.addEventListener('touchend', handleDragEnd); } function handleDragStart(e: MouseEvent | TouchEvent): void { const header = e.target.closest('.panel-header') as HTMLElement; const panel = header?.closest('.dockable-panel') as HTMLElement; if (!panel) return; const panelId = panel.dataset.panel; const panelState = state.panels.get(panelId); // Check if locked if (panelState?.locked) return; const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; state.dragState = { panelId: panelId!, startX: clientX, startY: clientY, currentX: clientX, currentY: clientY, isLocked: panelState?.locked || false }; panel.classList.add('dragging'); } function handleDragMove(e: MouseEvent | TouchEvent): void { if (!state.dragState) return; const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; state.dragState.currentX = clientX; state.dragState.currentY = clientY; const panel = document.querySelector(`[data-panel="${state.dragState.panelId}"]`); const container = panel?.parentElement; if (container) { container.style.transform = `translateX(${clientX - state.dragState.startX}px)`; } } function handleDragEnd(e: MouseEvent | TouchEvent): void { if (!state.dragState) return; const { panelId, currentX } = state.dragState; const panel = document.querySelector(`[data-panel="${panelId}"]`); const container = panel?.parentElement; // Reset transform container.style.transform = ''; panel?.classList.remove('dragging'); // Determine drop zone const newSide = currentX < window.innerWidth / 2 ? 'left' : 'right'; const panelState = state.panels.get(panelId); if (panelState) { // Check if dropped in valid zone const isValidDrop = newSide === 'left' || newSide === 'right'; if (isValidDrop) { panelState.last_valid_side = panelState.side; // Save previous valid position panelState.side = newSide; } else { // Snap back to last valid position panelState.side = panelState.last_valid_side; } applyPanelState(panelId, panelState); savePanelState(panelId, panelState); } state.dragState = null; } // Setup window-shade (collapse/expand) handlers function setupWindowShadeHandlers(): void { document.querySelectorAll('.window-shade-toggle').forEach(btn => { btn.addEventListener('click', (e) => { const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; const panelId = panel.dataset.panel; const panelState = state.panels.get(panelId); if (panelState) { panelState.collapsed = !panelState.collapsed; applyPanelState(panelId, panelState); savePanelState(panelId, panelState); } }); }); } // Setup lock toggle handlers function setupLockHandlers(): void { document.querySelectorAll('.panel-lock').forEach(btn => { btn.addEventListener('click', (e) => { const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; const panelId = panel.dataset.panel; const panelState = state.panels.get(panelId); if (panelState) { panelState.locked = !panelState.locked; applyPanelState(panelId, panelState); savePanelState(panelId, panelState); } }); }); } // Persist panel state to settings async function savePanelState(panelId: string, panelState: PanelState): Promise { const settings = loadSettings(); settings.panel_layout[panelId as keyof typeof settings.panel_layout] = panelState; await saveSettings(settings); } export { initializePanelDockSystem, registerPanel, applyPanelState }; ``` ``` --- ## 5. Ebook Reader Implementation ### 5.1 Parser Manager (Procedural) **File:** `web/src/reader/parser-manager.ts` ```typescript // Parser Manager - Routes files to appropriate parsers // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Parser Registry // ============================================================ const PARSER_REGISTRY: ParserEntry[] = [ { format: 'epub', mimeType: 'application/epub+zip', extensions: ['.epub'], side: 'client' }, { format: 'fb2', mimeType: 'application/fb2', extensions: ['.fb2', '.fb2.zip'], side: 'client' }, { format: 'txt', mimeType: 'text/plain', extensions: ['.txt'], side: 'client' }, { format: 'html', mimeType: 'text/html', extensions: ['.html', '.htm'], side: 'client' }, { format: 'mobi', mimeType: 'application/x-mobipocket-ebook', extensions: ['.mobi', '.azw'], side: 'server' }, { format: 'azw3', mimeType: 'application/vnd.amazon.mobi8-ebook', extensions: ['.azw3'], side: 'server' }, { format: 'docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'], side: 'server' }, { format: 'rtf', mimeType: 'application/rtf', extensions: ['.rtf'], side: 'server' }, ]; interface ParserEntry { format: string; mimeType: string; extensions: string[]; side: 'client' | 'server'; } // ============================================================ // Parser Detection // ============================================================ export function detectParserFormat(mimeType: string, extension: string): ParserEntry | null { return PARSER_REGISTRY.find(entry => entry.mimeType === mimeType || entry.extensions.includes(extension.toLowerCase()) ) || null; } export function requiresServerParsing(mimeType: string, extension: string): boolean { const entry = detectParserFormat(mimeType, extension); return entry?.side === 'server' || false; } // ============================================================ // Main Parse Function (Router) // ============================================================ export async function parseEbook(file: Blob, mimeType: string, extension: string): Promise { const entry = detectParserFormat(mimeType, extension); if (!entry) { throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`); } if (entry.side === 'server') { return parseEbookOnServer(file, entry.format); } else { return parseEbookOnClient(file, entry.format); } } // ============================================================ // Client-Side Parsing // ============================================================ async function parseEbookOnClient(file: Blob, format: string): Promise { switch (format) { case 'epub': return parseEPUB(file); case 'fb2': return parseFB2(file); case 'txt': return parseTXT(file); case 'html': return parseHTML(file); default: throw new Error(`Client-side parser not implemented for: ${format}`); } } // ============================================================ // Server-Side Parsing (API Call) // ============================================================ async function parseEbookOnServer(file: Blob, format: string): Promise { const formData = new FormData(); formData.append('file', file); formData.append('format', format); const response = await fetch('/api/readers/parse', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`Server parsing failed: ${response.statusText}`); } return await response.json(); } ``` --- ### 5.2 EPUB Parser (Refactored - Procedural) **File:** `web/src/reader/parsers/epub-parser.ts` ```typescript // EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Main Parse Function // ============================================================ export async function parseEPUB(epubBlob: Blob): Promise { const zip = await JSZip.loadAsync(epubBlob); // Parse container.xml to find OPF file const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const opfPath = extractOPFPath(containerXml); if (!opfPath) { throw new Error('Invalid EPUB: no OPF file found'); } // Parse OPF file const opfXml = await getZipFileContent(zip, opfPath); const packageDoc = parseXML(opfXml); // Extract all components const metadata = extractMetadata(packageDoc); const spine = parseSpine(packageDoc); const toc = await parseTOC(zip, packageDoc, opfPath); const resources = await loadResources(zip); const coverImage = await extractCover(zip, packageDoc); // Calculate locations (minimal - backend handles detailed tracking) const totalCharacters = await calculateTotalCharacters(spine, resources); return { metadata, toc, spine, resources, locations: { totalCharacters, estimatedPages: Math.ceil(totalCharacters / 1500), }, }; } // ============================================================ // Helper Functions // ============================================================ async function getZipFileContent(zip: JSZip, path: string): Promise { const file = zip.file(path); if (!file) { throw new Error(`File not found: ${path}`); } return await file.async('text'); } function parseXML(xmlString: string): XMLDocument { const parser = new DOMParser(); return parser.parseFromString(xmlString, 'text/xml'); } function extractOPFPath(containerXml: string): string | null { const containerDoc = parseXML(containerXml); return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null; } function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] { const metadata = packageDoc.querySelector('metadata'); if (!metadata) { throw new Error('No metadata found in OPF'); } return { title: metadata.querySelector('title')?.textContent || '', author: metadata.querySelector('creator')?.textContent || '', language: metadata.querySelector('language')?.textContent || 'en', publisher: metadata.querySelector('publisher')?.textContent || undefined, isbn: metadata.querySelector('identifier')?.textContent || undefined, }; } function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] { const spine = packageDoc.querySelector('spine'); const manifest = packageDoc.querySelector('manifest'); if (!spine || !manifest) { throw new Error('No spine or manifest found in OPF'); } const spineItems = spine.querySelectorAll('itemref'); const result: EbookCIF['spine'] = []; spineItems.forEach((itemref) => { const idref = itemref.getAttribute('idref'); if (!idref) return; const manifestItem = manifest.querySelector(`[id="${idref}"]`); if (!manifestItem) return; const href = manifestItem.getAttribute('href'); if (!href) return; result.push({ id: idref, type: 'html', content: href, properties: itemref.getAttribute('properties') || undefined, }); }); return result; } async function parseTOC(zip: JSZip, packageDoc: XMLDocument, opfPath: string): Promise { // Try EPUB 3.0 navigation document first const navItem = packageDoc.querySelector('manifest item[properties~="nav"]'); if (navItem) { const navHref = navItem.getAttribute('href'); if (navHref) { const navPath = resolvePath(opfPath, navHref); return parseNavTOC(zip, navPath); } } // Fallback to EPUB 2.0 NCX const ncxId = spine?.getAttribute('toc'); if (ncxId) { const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`); if (ncxItem) { const ncxHref = ncxItem.getAttribute('href'); if (ncxHref) { const ncxPath = resolvePath(opfPath, ncxHref); return parseNCXTOC(zip, ncxPath); } } } return []; } async function parseNavTOC(zip: JSZip, navPath: string): Promise { const navXml = await getZipFileContent(zip, navPath); const navDoc = parseXML(navXml); const nav = navDoc.querySelector('nav'); if (!nav) return []; const ol = nav.querySelector('ol'); if (!ol) return []; const items = ol.querySelectorAll(':scope > li'); const result: EbookCIF['toc'] = []; for (const li of items) { const link = li.querySelector('a'); if (link) { result.push({ id: link.getAttribute('href') || '', title: link.textContent || '', href: link.getAttribute('href') || '', children: [], }); } } return result; } async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise { const ncxXml = await getZipFileContent(zip, ncxPath); const ncxDoc = parseXML(ncxXml); const navMap = ncxDoc.querySelector('navMap'); if (!navMap) return []; return parseNCXNode(navMap); } function parseNCXNode(node: Element): EbookCIF['toc'] { const navPoints = node.querySelectorAll(':scope > navPoint'); const result: EbookCIF['toc'] = []; navPoints.forEach((navPoint) => { const label = navPoint.querySelector('navLabel text')?.textContent || ''; const content = navPoint.querySelector('content'); const href = content?.getAttribute('src') || ''; result.push({ id: href, title: label, href, children: parseNCXNode(navPoint), }); }); return result; } async function loadResources(zip: JSZip): Promise> { const resources = new Map(); const files = Object.keys(zip.files); for (const path of files) { const file = zip.file(path); if (file && !file.dir) { const blob = await file.async('blob'); resources.set(path, blob); } } return resources; } async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise { // Try cover-id metadata const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content'); if (coverId) { const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`); if (coverItem) { const coverHref = coverItem.getAttribute('href'); if (coverHref) { const coverFile = zip.file(coverHref); if (coverFile) { return await coverFile.async('blob'); } } } } // Fallback: look for cover image in manifest const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]'); if (coverItem) { const coverHref = coverItem.getAttribute('href'); if (coverHref) { const coverFile = zip.file(coverHref); if (coverFile) { return await coverFile.async('blob'); } } } return undefined; } function resolvePath(basePath: string, relativePath: string): string { const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); return baseDir + relativePath; } async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map): Promise { let total = 0; for (const item of spine) { if (item.type === 'html') { const content = resources.get(item.content); if (content) { const text = await content.text(); total += text.length; } } } return total; } function resolvePath(basePath: string, relativePath: string): string { const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); return baseDir + relativePath; } } } return total; } function generatePageBreaks(totalCharacters: number): number[] { const breaks: number[] = []; const charsPerPage = 1000; // Rough estimate for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) { breaks.push(i); } return breaks; } // ============================================================ // Metadata Quick Extract (for library view) // ============================================================ export async function extractEPUBMetadata(epubBlob: Blob): Promise> { const zip = await JSZip.loadAsync(epubBlob); const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const opfPath = extractOPFPath(containerXml); if (!opfPath) { return {}; } const opfXml = await getZipFileContent(zip, opfPath); const packageDoc = parseXML(opfXml); return extractMetadata(packageDoc); } ``` --- ### 5.3 FictionBook 2 (FB2) Parser **File:** `web/src/reader/parsers/fb2-parser.ts` ```typescript // FB2 Parser - Converts FictionBook 2 to Common Intermediate Format // FB2 is XML-based, similar to EPUB structure // Procedural style: Functions, not classes import JSZip from 'jszip'; // ============================================================ // Main Parse Function // ============================================================ export async function parseFB2(fb2Blob: Blob): Promise { // FB2 can be plain XML or zipped (.fb2.zip) let xmlContent: string; if (fb2Blob.type === 'application/zip' || fb2Blob.type === 'application/x-zip-compressed') { const zip = await JSZip.loadAsync(fb2Blob); const files = Object.keys(zip.files); // Find the first .fb2 file in the zip const fb2File = files.find(f => f.endsWith('.fb2')); if (!fb2File) { throw new Error('No .fb2 file found in archive'); } xmlContent = await zip.file(fb2File)!.async('text'); } else { xmlContent = await fb2Blob.text(); } const xmlDoc = parseXML(xmlContent); const metadata = extractFB2Metadata(xmlDoc); const toc = parseFB2TOC(xmlDoc); const spine = createFB2Spine(xmlDoc); const resources = await extractFB2Resources(xmlDoc, fb2Blob); // Calculate locations (minimal - backend handles detailed tracking) const totalCharacters = calculateFB2Characters(xmlDoc); return { metadata, toc, spine, resources, locations: { totalCharacters, estimatedPages: Math.ceil(totalCharacters / 1500), }, }; } // ============================================================ // Helper Functions // ============================================================ function parseXML(xmlString: string): XMLDocument { const parser = new DOMParser(); return parser.parseFromString(xmlString, 'text/xml'); } function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF['metadata'] { const titleInfo = xmlDoc.querySelector('title-info'); const documentInfo = xmlDoc.querySelector('document-info'); if (!titleInfo) { throw new Error('Invalid FB2: no title-info found'); } return { title: titleInfo.querySelector('book-title')?.textContent || '', author: extractFB2Author(titleInfo), language: titleInfo.querySelector('lang')?.textContent || 'en', publisher: documentInfo?.querySelector('publisher')?.textContent || undefined, isbn: undefined, // FB2 doesn't typically have ISBN }; } function extractFB2Author(titleInfo: Element): string { const author = titleInfo.querySelector('author'); if (!author) return ''; const firstName = author.querySelector('first-name')?.textContent || ''; const lastName = author.querySelector('last-name')?.textContent || ''; const middleName = author.querySelector('middle-name')?.textContent || ''; const parts = [firstName, middleName, lastName].filter(Boolean); return parts.join(' ') || 'Unknown'; } function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF['toc'] { const toc: EbookCIF['toc'] = []; const body = xmlDoc.querySelector('body'); if (!body) return toc; const sections = body.querySelectorAll(':scope > section'); let sectionIndex = 0; for (const section of sections) { const title = section.querySelector('title'); const titleText = title?.textContent.trim() || `Section ${sectionIndex + 1}`; toc.push({ id: `section-${sectionIndex}`, title: titleText, href: `#section-${sectionIndex}`, children: [], }); sectionIndex++; } return toc; } function createFB2Spine(xmlDoc: XMLDocument): EbookCIF['spine'] { const spine: EbookCIF['spine'] = []; const body = xmlDoc.querySelector('body'); if (!body) return spine; // Convert each section to HTML const sections = body.querySelectorAll(':scope > section'); sections.forEach((section, index) => { const htmlContent = convertFB2SectionToHTML(section, index); spine.push({ id: `section-${index}`, type: 'html', content: htmlContent, index, }); }); return spine; } function convertFB2SectionToHTML(section: Element, index: number): string { const title = section.querySelector('title'); let html = `
`; if (title) { html += `

${title.textContent}

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

${p.innerHTML}

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

${escapeHTML(trimmed)}

`; } else { htmlContent += '
'; } }); htmlContent += '
'; return [{ id: 'full-text', type: 'html', content: htmlContent, index: 0, }]; } function escapeHTML(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Removed - backend handles detailed position tracking // ============================================================ // Metadata Quick Extract // ============================================================ export async function extractTXTMetadata(txtBlob: Blob): Promise> { return extractTXTMetadata(txtBlob); } ``` --- ### 5.5 HTML Parser **File:** `web/src/reader/parsers/html-parser.ts` ```typescript // HTML Parser - Wraps standalone HTML files // Procedural style: Functions, not classes // ============================================================ // Main Parse Function // ============================================================ export async function parseHTML(htmlBlob: Blob): Promise { const htmlContent = await htmlBlob.text(); const metadata = extractHTMLMetadata(htmlBlob, htmlContent); const toc = createHTMLTOC(htmlContent); const spine = createHTMLSpine(htmlContent); const resources = await extractHTMLResources(htmlBlob, htmlContent); const totalCharacters = stripHTML(htmlContent).length; const pageBreaks = generatePageBreaks(totalCharacters); return { metadata, toc, spine, resources, locations: { totalCharacters, pageBreaks, }, }; } // ============================================================ // Helper Functions // ============================================================ function extractHTMLMetadata(htmlBlob: Blob, htmlContent: string): EbookCIF['metadata'] { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); const title = doc.querySelector('title')?.textContent || htmlBlob.name.replace(/\.(html?|htm)$/i, ''); const metaAuthor = doc.querySelector('meta[name="author"]')?.getAttribute('content'); const metaLang = doc.querySelector('html')?.getAttribute('lang') || 'en'; return { title, author: metaAuthor || 'Unknown', language: metaLang, }; } function createHTMLTOC(htmlContent: string): EbookCIF['toc'] { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); const toc: EbookCIF['toc'] = []; // Try to find headings const headings = doc.querySelectorAll('h1, h2, h3'); let headingIndex = 0; headings.forEach(heading => { toc.push({ id: `heading-${headingIndex}`, title: heading.textContent || '', href: `#${heading.id || `heading-${headingIndex}`}`, children: [], }); headingIndex++; }); // If no headings, create single entry if (toc.length === 0) { toc.push({ id: 'full-document', title: 'Full Document', href: '#full-document', children: [], }); } return toc; } function createHTMLSpine(htmlContent: string): EbookCIF['spine'] { return [{ id: 'full-document', type: 'html', content: htmlContent, index: 0, }]; } async function extractHTMLResources(htmlBlob: Blob, htmlContent: string): Promise> { const resources = new Map(); const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); // Extract images const images = doc.querySelectorAll('img[src]'); for (const img of Array.from(images)) { const src = img.getAttribute('src'); if (!src) continue; // Try to resolve relative URLs if (src.startsWith('data:')) { // Data URI - extract blob const match = src.match(/^data:([^;]+);base64,(.+)$/); if (match) { const mimeType = match[1]; const base64 = match[2]; const byteString = atob(base64); const byteArray = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { byteArray[i] = byteString.charCodeAt(i); } const blob = new Blob([byteArray], { type: mimeType }); resources.set(src, blob); } } // External resources would need to be fetched // For now, skip them (browser will load them naturally) } return resources; } function stripHTML(html: string): string { const div = document.createElement('div'); div.innerHTML = html; return div.textContent || ''; } // ============================================================ // Metadata Quick Extract // ============================================================ export async function extractHTMLMetadata(htmlBlob: Blob): Promise> { const htmlContent = await htmlBlob.text(); return extractHTMLMetadata(htmlBlob, htmlContent); } ``` --- ### 5.6 HTML Renderer (Procedural) **File:** `web/src/reader/ebook/html-renderer.ts` ```typescript // HTML rendering with theme support, font loading, and image handling // Procedural style: Functions, not classes interface RendererConfig { readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; fontSize: number; lineHeight: number; marginWidth: number; textAlign: 'left' | 'justify'; columnCount: 1 | 2; } // ============================================================ // Main Render Function // ============================================================ export async function renderHTMLDocument( doc: HTMLDocument, container: HTMLElement, config: RendererConfig ): Promise { // Apply theme applyHTMLTheme(container, config.readingTheme); // Apply typography settings applyHTMLTypography(container, config); // Inject custom styles for reader injectHTMLReaderStyles(container); // Handle embedded fonts await loadEmbeddedHTMLFonts(doc, container); // Handle images processHTMLImages(doc, container); // Clear container and append content container.innerHTML = ''; container.appendChild(doc.body); // Apply column layout applyHTMLColumnLayout(container, config.columnCount); } // ============================================================ // Theme Application // ============================================================ function applyHTMLTheme(container: HTMLElement, theme: string): void { const readingThemes: Record> = { 'light': { '--bg-primary': '#ffffff', '--text-primary': '#1a1a1a', '--text-secondary': '#666666', '--accent': '#0066cc' }, 'sepia': { '--bg-primary': '#f4ecd8', '--text-primary': '#5f4b32', '--text-secondary': '#8b7355', '--accent': '#8b4513' }, 'dark': { '--bg-primary': '#1a1b26', '--text-primary': '#c0caf5', '--text-secondary': '#565f89', '--accent': '#7aa2f7' }, 'night': { '--bg-primary': '#0d1117', '--text-primary': '#c9d1d9', '--text-secondary': '#8b949e', '--accent': '#58a6ff' }, 'high-contrast': { '--bg-primary': '#000000', '--text-primary': '#ffffff', '--text-secondary': '#cccccc', '--accent': '#ffff00' } }; const themeConfig = readingThemes[theme] || readingThemes['dark']; for (const [key, value] of Object.entries(themeConfig)) { container.style.setProperty(key, value); } } function applyHTMLTypography(container: HTMLElement, config: RendererConfig): void { const style = document.createElement('style'); const fontStack = getFontStack(config.readingFont); style.textContent = ` .ebook-content { font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; padding: 0 ${config.marginWidth}px; max-width: 100%; overflow-wrap: break-word; } .ebook-content p { margin-bottom: 1em; text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; } .ebook-content img { max-width: 100%; height: auto; display: block; margin: 1em auto; } .ebook-content a { color: var(--accent); text-decoration: underline; } .ebook-content a:active { color: var(--text-secondary); } `; container.appendChild(style); } function injectHTMLReaderStyles(container: HTMLElement): void { container.setAttribute('role', 'main'); container.setAttribute('aria-label', 'Book content'); } async function loadEmbeddedHTMLFonts(doc: HTMLDocument, container: HTMLElement): Promise { const styleSheets = doc.querySelectorAll('style'); for (const sheet of styleSheets) { const fontFaceRegex = /@font-face\s*{([^}]+)}/g; const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; for (const match of matches) { const fontFace = match[1]; const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); if (urlMatch) { const fontUrl = urlMatch[1]; await loadHTMLFont(fontUrl, container); } } } } async function loadHTMLFont(fontUrl: string, container: HTMLElement): Promise { const loadedFonts = container.dataset.loadedFonts ? JSON.parse(container.dataset.loadedFonts) : []; if (loadedFonts.includes(fontUrl)) return; try { const fontFace = new FontFace('custom-font', `url(${fontUrl})`); await fontFace.load(); document.fonts.add(fontFace); loadedFonts.push(fontUrl); container.dataset.loadedFonts = JSON.stringify(loadedFonts); } catch (error) { console.error('Failed to load font:', fontUrl, error); } } function processHTMLImages(doc: HTMLDocument): void { const images = doc.querySelectorAll('img'); images.forEach((img) => { img.setAttribute('loading', 'lazy'); if (!img.alt) { img.alt = 'Image from book'; } img.style.cursor = 'pointer'; img.addEventListener('click', () => { showImageFullscreen(img.src); }); }); } function showImageFullscreen(src: string): void { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; modal.onclick = () => modal.remove(); const img = document.createElement('img'); img.src = src; img.className = 'max-w-full max-h-full object-contain'; modal.appendChild(img); document.body.appendChild(modal); } function applyHTMLColumnLayout(container: HTMLElement, columnCount: number): void { if (columnCount === 2) { container.style.columnCount = '2'; container.style.columnGap = '20px'; container.style.columnRule = '1px solid var(--text-secondary)'; } else { container.style.columnCount = 'auto'; } } function getFontStack(font: string): string { const stacks: Record = { 'literata': '"Literata", serif', 'crimson': '"Crimson Text", serif', 'source-serif': '"Source Serif 4", serif', 'eb-garamond': '"EB Garamond", serif', 'libertinus': '"Libertinus Serif", serif', 'noto-serif': '"Noto Serif", serif', 'charis-sil': '"Charis SIL", serif', 'ibm-plex': '"IBM Plex Serif", serif', }; return stacks[font] || stacks['literata']; } ``` --- ### 5.7 CFI Navigation (Procedural) **File:** `web/src/reader/ebook/cfi-navigator.ts` ```typescript // EPUB CFI (Canonical Fragment Identifier) navigation // Reuses logic from internal/sync/format.go // Procedural style: Functions, not classes interface CFIComponent { type: 'index' | 'indirection-step' | 'text-location'; value: number; id?: string; textOffset?: number; } // ============================================================ // CFI Parsing Functions // ============================================================ export function parseCFI(cfi: string): CFIComponent[] { const components: CFIComponent[] = []; const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi; const parts = cleanCFI.split('/').filter(Boolean); for (const part of parts) { const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); if (match) { const component: CFIComponent = { type: match[3] !== undefined ? 'text-location' : 'index', value: parseInt(match[1], 10), id: match[2], textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined }; components.push(component); } } return components; } export function generateCFI( spineIndex: number, elementPath: number[], textOffset: number = 0, spineItemId?: string ): string { let cfi = `/6/${spineIndex}`; if (spineItemId) { cfi += `[${spineItemId}]`; } for (const index of elementPath) { cfi += `/${index}`; } if (textOffset > 0) { cfi += `:${textOffset}`; } return cfi; } export function navigateToCFI(doc: Document, cfi: string): Element | Text | null { const components = parseCFI(cfi); if (components.length === 0) return null; let current: Node | null = doc.body; for (let i = 1; i < components.length; i++) { const component = components[i]; if (component.type === 'index') { if (current instanceof Element) { const children = getElementChildren(current); current = children[component.value] || null; } } } return current as Element | Text; } export function getSelectionCFI(doc: Document): string | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const startContainer = range.startContainer; // Build path to start container const path: number[] = []; let current: Node | null = startContainer; while (current && current !== doc.body) { const parent = current.parentElement; if (parent) { const siblings = getElementChildren(parent); const index = siblings.indexOf(current as Element); path.unshift(index); } current = parent; } const spineIndex = 0; const textOffset = range.startOffset; return generateCFI(spineIndex, path, textOffset); } export function getPercentageFromCFI(cfi: string): number { const components = parseCFI(cfi); const textLocation = components.find(c => c.type === 'text-location'); if (textLocation && textLocation.textOffset !== undefined) { return Math.min((textLocation.textOffset / 10), 100); } return 0; } export function compareCFIs(cfi1: string, cfi2: string): number { const components1 = parseCFI(cfi1); const components2 = parseCFI(cfi2); const maxLen = Math.max(components1.length, components2.length); for (let i = 0; i < maxLen; i++) { const comp1 = components1[i]; const comp2 = components2[i]; if (!comp1) return -1; if (!comp2) return 1; if (comp1.value !== comp2.value) { return comp1.value - comp2.value; } } return 0; } function getElementChildren(element: Element): Element[] { return Array.from(element.children).filter(el => el.nodeType === Node.ELEMENT_NODE ) as Element[]; } ``` --- ### 5.8 Typography Engine (Procedural) **File:** `web/src/reader/ebook/typography-engine.ts` ```typescript // Typography engine for ebook rendering // Procedural style: Functions, not classes interface TypographyConfig { fontSize: number; lineHeight: number; textAlign: 'left' | 'justify'; hyphenate: boolean; ligatures: boolean; fontSmoothing: 'auto' | 'grayscale'; } export function applyTypographyConfig( element: HTMLElement, config: TypographyConfig ): void { // Enable/disable ligatures setLigatures(element, config.ligatures); // Enable/disable hyphenation if (config.hyphenate) { enableHyphenation(element); } // Apply justification settings if (config.textAlign === 'justify') { enableJustification(element); } // Apply font smoothing element.style.fontSmooth = config.fontSmoothing; } function setLigatures(element: HTMLElement, enabled: boolean): void { if (enabled) { element.style.fontVariantLigatures = 'common-ligatures'; element.style.fontFeatureSettings = '"liga", "dlig"'; } else { element.style.fontVariantLigatures = 'no-common-ligatures'; element.style.fontFeatureSettings = 'normal'; } } function enableHyphenation(element: HTMLElement): void { element.style.hyphens = 'auto'; element.style.hyphenateLimitChars = '6 3 3'; // Add language attribute from EPUB metadata const lang = element.closest('[data-language]')?.getAttribute('data-language') || 'en'; element.setAttribute('lang', lang); } function enableJustification(element: HTMLElement): void { element.style.wordBreak = 'normal'; element.style.overflowWrap = 'break-word'; element.style.wordWrap = 'break-word'; element.style.letterSpacing = '0.01em'; } export function measureReadingTime( container: HTMLElement, wordsPerMinute: number = 250 ): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; const words = text.split(/\s+/).length; const minutes = words / wordsPerMinute; return Math.ceil(minutes); } export function getWordCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; return text.split(/\s+/).length; } ``` --- ### 5.9 Ebook Search (Procedural) **File:** `web/src/reader/ebook/search.ts` ```typescript // Search within ebook content // Procedural style: Functions, not classes interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } interface EbookSearchConfig { epubPackage: EPUBPackage; } // ============================================================ // Main Search Function // ============================================================ export async function searchEbook( epubPackage: EPUBPackage, query: string ): Promise { const results: SearchResult[] = []; const lowerQuery = query.toLowerCase(); // Search all spine items for (const [index, spineItem] of epubPackage.spine.entries()) { const doc = await getSpineItemDocument(epubPackage, spineItem); if (!doc) continue; const chapterTitle = getChapterTitle(spineItem); // Search in text nodes const textNodes = findTextNodes(doc.body); for (const node of textNodes) { const text = node.textContent || ''; const lowerText = text.toLowerCase(); let foundAt = 0; while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { const cfi = generateCFIForNode(node, foundAt); const snippet = extractSnippet(text, foundAt, query.length); results.push({ cfi, snippet, chapterTitle }); foundAt += lowerQuery.length; } } } return results; } async function getSpineItemDocument( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): Promise { try { const content = await epubPackage.resources.get(spineItem.href)?.text(); if (!content) return null; const parser = new DOMParser(); return parser.parseFromString(content, 'text/html'); } catch (error) { console.error('Failed to load spine item:', spineItem.href, error); return null; } } function getChapterTitle(spineItem: EPUBSpineItem): string { // Extract title from spine item or use default return spineItem.id || `Section ${spineItem.index}`; } function findTextNodes(root: Node): Text[] { const textNodes: Text[] = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { const parent = node.parentElement; if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { return NodeFilter.FILTER_REJECT; } if (!node.textContent?.trim()) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); let node: Node | null; while ((node = walker.nextNode())) { textNodes.push(node as Text); } return textNodes; } function generateCFIForNode(node: Text, offset: number): string { const path: number[] = []; let current: Node | null = node; while (current && current.parentNode) { const parent = current.parentNode; const siblings = Array.from(parent.childNodes) .filter(n => n.nodeType === Node.ELEMENT_NODE); const index = siblings.indexOf(current as Node); path.unshift(index); current = parent; } const spineIndex = 0; // Would come from parent context return generateCFI(spineIndex, path, offset); } function extractSnippet(text: string, offset: number, length: number): string { const contextBefore = 30; const contextAfter = 50; const start = Math.max(0, offset - contextBefore); const end = Math.min(text.length, offset + length + contextAfter); return text.slice(start, end); } ``` **File:** `web/src/reader/ebook/html-renderer.ts` ```typescript // HTML rendering with theme support, font loading, and image handling interface RendererConfig { readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; // Reading-optimized themes readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts fontSize: number; lineHeight: number; marginWidth: number; textAlign: 'left' | 'justify'; columnCount: 1 | 2; // Single or double column } // HTML rendering with theme support, font loading, and image handling // Procedural implementation (no OOP) const loadedFonts = new Set(); async function renderDocument( container: HTMLElement, doc: HTMLDocument, config: RendererConfig ): Promise { applyTheme(container, config.readingTheme); applyTypography(container, config); injectReaderStyles(container); await loadEmbeddedFonts(doc); processImages(doc); container.innerHTML = ''; container.appendChild(doc.body); applyColumnLayout(container, config); } function applyTheme( container: HTMLElement, readingTheme: RendererConfig['readingTheme'] ): void { const readingThemes: Record> = { 'light': { '--bg-primary': '#ffffff', '--text-primary': '#1a1a1a', '--text-secondary': '#666666', '--accent': '#0066cc' }, 'sepia': { '--bg-primary': '#f4ecd8', '--text-primary': '#5f4b32', '--text-secondary': '#8b7355', '--accent': '#8b4513' }, 'dark': { '--bg-primary': '#1a1b26', '--text-primary': '#c0caf5', '--text-secondary': '#565f89', '--accent': '#7aa2f7' }, 'night': { '--bg-primary': '#0d1117', '--text-primary': '#c9d1d9', '--text-secondary': '#8b949e', '--accent': '#58a6ff' }, 'high-contrast': { '--bg-primary': '#000000', '--text-primary': '#ffffff', '--text-secondary': '#cccccc', '--accent': '#ffff00' } }; const theme = readingThemes[readingTheme] || readingThemes['dark']; for (const [key, value] of Object.entries(theme)) { container.style.setProperty(key, value); } } function applyTypography(container: HTMLElement, config: RendererConfig): void { const style = document.createElement('style'); const fontStack = getFontStack(config.readingFont); style.textContent = ` .ebook-content { font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; padding: 0 ${config.marginWidth}px; max-width: 100%; overflow-wrap: break-word; } .ebook-content p { margin-bottom: 1em; text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'}; } .ebook-content img { max-width: 100%; height: auto; display: block; margin: 1em auto; } .ebook-content a { color: var(--accent); text-decoration: underline; } .ebook-content a:active { color: var(--text-secondary); } `; container.appendChild(style); } function injectReaderStyles(container: HTMLElement): void { container.setAttribute('role', 'main'); container.setAttribute('aria-label', 'Book content'); } async function loadEmbeddedFonts(doc: HTMLDocument): Promise { const styleSheets = doc.querySelectorAll('style'); for (const sheet of styleSheets) { const fontFaceRegex = /@font-face\s*{([^}]+)}/g; const matches = sheet.textContent?.matchAll(fontFaceRegex) || []; for (const match of matches) { const fontFace = match[1]; const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace); if (urlMatch) { const fontUrl = urlMatch[1]; await loadFont(fontUrl); } } } } async function loadFont(fontUrl: string): Promise { if (loadedFonts.has(fontUrl)) return; try { const fontFace = new FontFace('custom-font', `url(${fontUrl})`); await fontFace.load(); document.fonts.add(fontFace); loadedFonts.add(fontUrl); } catch (error) { console.error('Failed to load font:', fontUrl, error); } } function processImages(doc: HTMLDocument): void { const images = doc.querySelectorAll('img'); images.forEach((img) => { img.setAttribute('loading', 'lazy'); if (!img.alt) { img.alt = 'Image from book'; } img.style.cursor = 'pointer'; img.addEventListener('click', () => { showImageFullscreen(img.src); }); }); } function showImageFullscreen(src: string): void { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50'; modal.onclick = () => modal.remove(); const img = document.createElement('img'); img.src = src; img.className = 'max-w-full max-h-full object-contain'; modal.appendChild(img); document.body.appendChild(modal); } function applyColumnLayout(container: HTMLElement, config: RendererConfig): void { if (config.columnCount === 2) { container.style.columnCount = '2'; container.style.columnGap = `${config.marginWidth}px`; container.style.columnRule = '1px solid var(--text-secondary)'; } else { container.style.columnCount = 'auto'; } } function updateRendererConfig( container: HTMLElement, currentConfig: RendererConfig, newConfig: Partial ): RendererConfig { const updatedConfig = { ...currentConfig, ...newConfig }; const currentDoc = container.querySelector('.ebook-content')?.ownerDocument; if (currentDoc) { renderDocument(container, currentDoc as HTMLDocument, updatedConfig); } return updatedConfig; } ``` ### 5.10 Libre Reading Fonts (Bundled) **8 Open Source Fonts Optimized for Extended Reading** All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i). **Font Directory:** `web/static/fonts/` #### 5.10.1 Font Acquisition & Installation **Automated Setup Script** **File:** `scripts/fetch-reading-fonts.sh` (new file) ```bash #!/bin/bash # Fetch and prepare libre reading fonts for Bookhoard # Usage: ./scripts/fetch-reading-fonts.sh set -e FONTS_DIR="web/static/fonts" mkdir -p "$FONTS_DIR" echo "πŸ“¦ Downloading libre reading fonts for Bookhoard..." # 1. Literata (v2.001 - latest stable) echo "Downloading Literata..." wget -O /tmp/literata.zip "https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip" unzip -q /tmp/literata.zip -d /tmp/literata mkdir -p "$FONTS_DIR/literata" # Convert to WOFF2 using fonttools for file in /tmp/literata/Static/*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' elif [[ $basename == *"Italic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' elif [[ $basename == *"BoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF' fi done rm -rf /tmp/literata /tmp/literata.zip # 2. Crimson Text (v1.102) echo "Downloading Crimson Text..." wget -O /tmp/crimson.zip "https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip" unzip -q /tmp/crimson.zip -d /tmp/crimson mkdir -p "$FONTS_DIR/crimson" for file in /tmp/crimson/OTF/CrimsonPro-*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Roman"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400.woff2" --flavor=woff2 elif [[ $basename == *"Roman-Italic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400i.woff2" --flavor=woff2 elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700.woff2" --flavor=woff2 elif [[ $basename == *"BoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/crimson /tmp/crimson.zip # 3. Source Serif 4 (v4.004) echo "Downloading Source Serif 4..." wget -O /tmp/source-serif.zip "https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip" unzip -q /tmp/source-serif.zip -d /tmp/source-serif mkdir -p "$FONTS_DIR/source-serif" for file in /tmp/source-serif/OTF/SourceSerif4-*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400.woff2" --flavor=woff2 elif [[ $basename == *"It"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400i.woff2" --flavor=woff2 elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700.woff2" --flavor=woff2 elif [[ $basename == *"BoldIt"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/source-serif /tmp/source-serif.zip # 4. EB Garamond (v0.016) echo "Downloading EB Garamond..." wget -O /tmp/ebgaramond.zip "https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip" unzip -q /tmp/ebgaramond.zip -d /tmp/ebgaramond mkdir -p "$FONTS_DIR/eb-garamond" for file in /tmp/ebgaramond/otf/*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400.woff2" --flavor=woff2 elif [[ $basename == *"Italic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400i.woff2" --flavor=woff2 elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700.woff2" --flavor=woff2 elif [[ $basename == *"BoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/ebgaramond /tmp/ebgaramond.zip # 5. Libertinus Serif (v7.050) echo "Downloading Libertinus Serif..." wget -O /tmp/libertinus.zip "https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip" unzip -q /tmp/libertinus.zip -d /tmp/libertinus mkdir -p "$FONTS_DIR/libertinus" for file in /tmp/libertinus/LibertinusSerif-*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400.woff2" --flavor=woff2 elif [[ $basename == *"Italic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400i.woff2" --flavor=woff2 elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700.woff2" --flavor=woff2 elif [[ $basename == *"BoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/libertinus /tmp/libertinus.zip # 6. Noto Serif (v2.013 - subset to common languages only to reduce size) echo "Downloading Noto Serif..." wget -O /tmp/noto-serif.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Regular.ttf" pyftsubset /tmp/noto-serif.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400.woff2" --flavor=woff2 --unicodes='U+0000-007F' --text-file="common-latin.txt" wget -O /tmp/noto-serif-i.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Italic.ttf" pyftsubset /tmp/noto-serif-i.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400i.woff2" --flavor=woff2 --unicodes='U+0000-007F' wget -O /tmp/noto-serif-b.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf" pyftsubset /tmp/noto-serif-b.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700.woff2" --flavor=woff2 --unicodes='U+0000-007F' wget -O /tmp/noto-serif-bi.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf" pyftsubset /tmp/noto-serif-bi.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700i.woff2" --flavor=woff2 --unicodes='U+0000-007F' rm -f /tmp/noto-serif*.zip # 7. Charis SIL (v6.200) echo "Downloading Charis SIL..." wget -O /tmp/charis.zip "https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip" unzip -q /tmp/charis.zip -d /tmp/charis mkdir -p "$FONTS_DIR/charis-sil" for file in /tmp/charis/CharisSIL-6.200/*.ttf; do basename=$(basename "$file" .ttf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400.woff2" --flavor=woff2 elif [[ $basename == *"Italic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400i.woff2" --flavor=woff2 elif [[ $basename == *"Bold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700.woff2" --flavor=woff2 elif [[ $basename == *"BoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/charis /tmp/charis.zip # 8. IBM Plex Serif (v1.1.0) echo "Downloading IBM Plex Serif..." wget -O /tmp/ibm-plex.zip "https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip" unzip -q /tmp/ibm-plex.zip -d /tmp/ibm-plex mkdir -p "$FONTS_DIR/ibm-plex" for file in /tmp/ibm-plex/OpenType/IBM-Plex-Serif/*.otf; do basename=$(basename "$file" .otf) if [[ $basename == *"Regular"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400.woff2" --flavor=woff2 elif [[ $basename == *"TextItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400i.woff2" --flavor=woff2 elif [[ $basename == *"SemiBold"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700.woff2" --flavor=woff2 elif [[ $basename == *"SemiBoldItalic"* ]]; then pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700i.woff2" --flavor=woff2 fi done rm -rf /tmp/ibm-plex /tmp/ibm-plex.zip echo "βœ… All fonts downloaded and converted to WOFF2" echo "πŸ“Š Total size:" du -sh "$FONTS_DIR" echo "πŸ” Verifying fonts..." ls -lh "$FONTS_DIR"/*/ echo "✨ Font setup complete!" ``` **Manual Setup (Alternative)** If you prefer manual setup or the script fails: | Font | Version | Download URL | License | |------|---------|-------------|---------| | **Literata** | v2.001 | https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip | SIL OFL 1.1 | | **Crimson Text** | v1.102 | https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip | SIL OFL 1.1 | | **Source Serif 4** | v4.004 | https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip | SIL OFL 1.1 | | **EB Garamond** | v0.016 | https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip | SIL OFL 1.1 | | **Libertinus Serif** | v7.050 | https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip | SIL OFL 1.1 | | **Noto Serif** | v2.013 | https://github.com/googlefonts/noto-fonts (subset to Latin-1) | SIL OFL 1.1 | | **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 | | **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 | #### 5.10.2 Font Conversion Requirements **Required Tools:** ```bash # Python fonttools for WOFF2 conversion pip install fonttools brotli # Alternative: Google Fonts woff2 tool git clone --recursive https://github.com/google/woff2.git cd woff2 make sudo cp woff2_compress /usr/local/bin/ sudo cp woff2_decompress /usr/local/bin/ ``` **Conversion Commands:** ```bash # Using fonttools (recommended) pyftsubset input.otf --output-file=output.woff2 \ --flavor=woff2 \ --layout-features='*' \ --unicodes='U+0000-10FFFF' # Using woff2_compress tool woff2_compress input.otf output.woff2 ``` #### 5.10.3 Font Verification **Verify fonts are working:** ```bash # List all fonts ls -lh web/static/fonts/*/ # Check file sizes (should be ~100-200KB each) du -h web/static/fonts/*/*.* # Verify WOFF2 format file web/static/fonts/*/*.woff2 # Should output: "WOFF2 font data" ``` **Add to git:** ```bash # Add fonts to repository git add web/static/fonts/ # Commit git commit -m "feat: add 8 bundled libre reading fonts - Literata (default) - Crimson Text - Source Serif 4 - EB Garamond - Libertinus Serif - Noto Serif - Charis SIL - IBM Plex Serif All fonts use SIL Open Font License 1.1 WOFF2 format, ~1.2MB total" ``` #### 5.10.4 Font Loading in Templates **File:** `templates/reader.templ` (updated) Add to `` section: ```go templ Reader(user User, metadata ReaderMetadata) { { metadata.title } - Bookhoard Reader

{ metadata.title }

-- / --
} ``` #### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended) If you don't want to bundle fonts (slower initial load, privacy concerns): ```html ``` **Why bundling is better:** - βœ… Offline-ready (no network requests) - βœ… Privacy (Google doesn't track usage) - βœ… Faster (no DNS lookup, no TLS handshake) - βœ… Control (exact versions, no breaking changes) #### 5.10.6 Font Subsetting for Language Support **Full Unicode vs. Latin-1 Subset:** - **Full Unicode**: ~200KB per style (supports all languages) - **Latin-1 Subset**: ~50KB per style (supports Western European languages) **Recommendation:** Bundle full Unicode for most fonts, but subset Noto Serif to Latin-1 unless you need extensive language support. **Subset Noto Serif (Latin-1 only):** ```bash pyftsubset NotoSerif-Regular.ttf \ --output-file=NotoSerif-400.woff2 \ --flavor=woff2 \ --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215' ``` This reduces Noto Serif from ~180KB to ~50KB per style. #### 5.10.7 Font Loading Performance **Critical Rendering Path Optimization:** ```typescript // Preload default font (Literata) in HTML head // Preload user's preferred font (from settings) ``` **Lazy-load other fonts:** ```typescript // Load other fonts on demand async function loadFont(fontId: string): Promise { const font = READING_FONTS.find(f => f.id === fontId); if (!font) return; document.fonts.load(`16px "${font.stack}"`); } ``` ``` web/static/fonts/ β”œβ”€β”€ literata/ β”‚ β”œβ”€β”€ Literata-400.woff2 (200KB) β”‚ β”œβ”€β”€ Literata-400i.woff2 (200KB) β”‚ β”œβ”€β”€ Literata-700.woff2 (180KB) β”‚ └── Literata-700i.woff2 (180KB) β”œβ”€β”€ crimson/ β”‚ β”œβ”€β”€ CrimsonText-400.woff2 (100KB) β”‚ β”œβ”€β”€ CrimsonText-400i.woff2 (100KB) β”‚ β”œβ”€β”€ CrimsonText-700.woff2 (95KB) β”‚ └── CrimsonText-700i.woff2 (95KB) β”œβ”€β”€ source-serif/ β”‚ β”œβ”€β”€ SourceSerif4-400.woff2 (150KB) β”‚ β”œβ”€β”€ SourceSerif4-400i.woff2 (150KB) β”‚ β”œβ”€β”€ SourceSerif4-700.woff2 (145KB) β”‚ └── SourceSerif4-700i.woff2 (145KB) β”œβ”€β”€ eb-garamond/ β”‚ β”œβ”€β”€ EBGaramond-400.woff2 (120KB) β”‚ β”œβ”€β”€ EBGaramond-400i.woff2 (120KB) β”‚ β”œβ”€β”€ EBGaramond-700.woff2 (115KB) β”‚ └── EBGaramond-700i.woff2 (115KB) β”œβ”€β”€ libertinus/ β”‚ β”œβ”€β”€ LibertinusSerif-400.woff2 (150KB) β”‚ β”œβ”€β”€ LibertinusSerif-400i.woff2 (150KB) β”‚ β”œβ”€β”€ LibertinusSerif-700.woff2 (145KB) β”‚ └── LibertinusSerif-700i.woff2 (145KB) β”œβ”€β”€ noto-serif/ β”‚ β”œβ”€β”€ NotoSerif-400.woff2 (180KB) β”‚ β”œβ”€β”€ NotoSerif-400i.woff2 (180KB) β”‚ β”œβ”€β”€ NotoSerif-700.woff2 (175KB) β”‚ └── NotoSerif-700i.woff2 (175KB) β”œβ”€β”€ charis-sil/ β”‚ β”œβ”€β”€ CharisSIL-400.woff2 (130KB) β”‚ β”œβ”€β”€ CharisSIL-400i.woff2 (130KB) β”‚ β”œβ”€β”€ CharisSIL-700.woff2 (125KB) β”‚ └── CharisSIL-700i.woff2 (125KB) └── ibm-plex/ β”œβ”€β”€ IBMPlexSerif-400.woff2 (140KB) β”œβ”€β”€ IBMPlexSerif-400i.woff2 (140KB) β”œβ”€β”€ IBMPlexSerif-700.woff2 (135KB) └── IBMPlexSerif-700i.woff2 (135KB) ``` **File:** `web/static/reader-fonts.css` (new file) ```css /* Libre reading fonts for Bookhoard ebook reader */ /* Literata - Designed for Google Play Books */ @font-face { font-family: 'Literata'; src: url('/static/fonts/literata/Literata-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Literata'; src: url('/static/fonts/literata/Literata-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Literata'; src: url('/static/fonts/literata/Literata-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Literata'; src: url('/static/fonts/literata/Literata-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* Crimson Text - Optimized for screen reading */ @font-face { font-family: 'Crimson Text'; src: url('/static/fonts/crimson/CrimsonText-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Crimson Text'; src: url('/static/fonts/crimson/CrimsonText-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Crimson Text'; src: url('/static/fonts/crimson/CrimsonText-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Crimson Text'; src: url('/static/fonts/crimson/CrimsonText-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* Source Serif 4 - Adobe professional quality */ @font-face { font-family: 'Source Serif 4'; src: url('/static/fonts/source-serif/SourceSerif4-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Source Serif 4'; src: url('/static/fonts/source-serif/SourceSerif4-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Source Serif 4'; src: url('/static/fonts/source-serif/SourceSerif4-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Source Serif 4'; src: url('/static/fonts/source-serif/SourceSerif4-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* EB Garamond - Classic elegance */ @font-face { font-family: 'EB Garamond'; src: url('/static/fonts/eb-garamond/EBGaramond-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'EB Garamond'; src: url('/static/fonts/eb-garamond/EBGaramond-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'EB Garamond'; src: url('/static/fonts/eb-garamond/EBGaramond-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'EB Garamond'; src: url('/static/fonts/eb-garamond/EBGaramond-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* Libertinus Serif - Academic/technical */ @font-face { font-family: 'Libertinus Serif'; src: url('/static/fonts/libertinus/LibertinusSerif-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Libertinus Serif'; src: url('/static/fonts/libertinus/LibertinusSerif-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Libertinus Serif'; src: url('/static/fonts/libertinus/LibertinusSerif-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Libertinus Serif'; src: url('/static/fonts/libertinus/LibertinusSerif-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* Noto Serif - Maximum language support */ @font-face { font-family: 'Noto Serif'; src: url('/static/fonts/noto-serif/NotoSerif-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Noto Serif'; src: url('/static/fonts/noto-serif/NotoSerif-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Noto Serif'; src: url('/static/fonts/noto-serif/NotoSerif-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Noto Serif'; src: url('/static/fonts/noto-serif/NotoSerif-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* Charis SIL - Multilingual specialist */ @font-face { font-family: 'Charis SIL'; src: url('/static/fonts/charis-sil/CharisSIL-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Charis SIL'; src: url('/static/fonts/charis-sil/CharisSIL-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'Charis SIL'; src: url('/static/fonts/charis-sil/CharisSIL-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'Charis SIL'; src: url('/static/fonts/charis-sil/CharisSIL-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } /* IBM Plex Serif - Modern & versatile */ @font-face { font-family: 'IBM Plex Serif'; src: url('/static/fonts/ibm-plex/IBMPlexSerif-400.woff2') format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'IBM Plex Serif'; src: url('/static/fonts/ibm-plex/IBMPlexSerif-400i.woff2') format('woff2'); font-weight: 400; font-style: italic; } @font-face { font-family: 'IBM Plex Serif'; src: url('/static/fonts/ibm-plex/IBMPlexSerif-700.woff2') format('woff2'); font-weight: 700; font-style: normal; } @font-face { font-family: 'IBM Plex Serif'; src: url('/static/fonts/ibm-plex/IBMPlexSerif-700i.woff2') format('woff2'); font-weight: 700; font-style: italic; } ``` **Font Loading Strategy:** **File:** `web/src/reader/ebook/font-loader.ts` (new file) ```typescript // Font loading with performance optimization const READING_FONTS = [ { id: 'literata', name: 'Literata', stack: 'Literata, serif', description: 'Designed for Google Play Books' }, { id: 'crimson', name: 'Crimson Text', stack: 'Crimson Text, serif', description: 'Optimized for screen reading' }, { id: 'source-serif', name: 'Source Serif 4', stack: 'Source Serif 4, serif', description: 'Professional Adobe quality' }, { id: 'eb-garamond', name: 'EB Garamond', stack: 'EB Garamond, serif', description: 'Classic elegance' }, { id: 'libertinus', name: 'Libertinus Serif', stack: 'Libertinus Serif, serif', description: 'Excellent for technical content' }, { id: 'noto-serif', name: 'Noto Serif', stack: 'Noto Serif, serif', description: 'Maximum language support' }, { id: 'charis-sil', name: 'Charis SIL', stack: 'Charis SIL, serif', description: 'Multilingual specialist' }, { id: 'ibm-plex', name: 'IBM Plex Serif', stack: 'IBM Plex Serif, serif', description: 'Modern & versatile' } ]; // Preload critical fonts (default font + user's last choice) async function preloadFonts(userPreferredFont: string): Promise { const fontsToPreload = new Set(['literata', userPreferredFont]); for (const fontId of fontsToPreload) { const font = READING_FONTS.find(f => f.id === fontId); if (font) { document.fonts.load(`16px "${font.stack}"`); } } } // Get font stack for CSS function getFontStack(fontId: string): string { const font = READING_FONTS.find(f => f.id === fontId); return font?.stack || 'Literata, serif'; } // All fonts bundled - no network requests needed export { READING_FONTS, preloadFonts, getFontStack }; ``` **Important Notes:** - **UI Elements**: Use Bookhoard's existing font stack (not these reading fonts) - **Ebook Content Only**: These fonts apply only to `.ebook-content` elements - **Bundled**: All fonts ship with the app (~1.2MB total, WOFF2 compressed) - **Offline Ready**: No network requests needed for font loading - **Performance**: Preload default font (Literata) + user's preference - **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK) ### 5.11 Typography Engine **File:** `web/src/reader/ebook/typography-engine.ts` ```typescript // Typography engine with font smoothing, hyphenation, and justification interface TypographyConfig { readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts fontSize: number; lineHeight: number; marginTop: number; marginBottom: number; marginLeft: number; marginRight: number; textAlign: 'left' | 'right' | 'center' | 'justify'; textIndent: number; hyphenate: boolean; ligatures: boolean; fontSmoothing: 'auto' | 'antialiased' | 'subpixel-antialiased'; } // Typography engine for ebook text rendering // Procedural implementation (no OOP) interface TypographyConfig { readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; fontSize: number; lineHeight: number; textAlign: 'left' | 'justify'; marginTop: number; marginBottom: number; marginLeft: number; marginRight: number; textIndent: number; fontSmoothing: 'antialiased' | 'auto' | 'grayscale'; hyphenate: boolean; ligatures: boolean; } function applyTypography(container: HTMLElement, config: TypographyConfig): void { const content = container.querySelector('.ebook-content'); if (!content) return; const fontStack = getFontStack(config.readingFont); content.setAttribute('style', ` font-family: ${fontStack}; font-size: ${config.fontSize}px; line-height: ${config.lineHeight}; text-align: ${config.textAlign}; margin-top: ${config.marginTop}px; margin-bottom: ${config.marginBottom}px; margin-left: ${config.marginLeft}px; margin-right: ${config.marginRight}px; text-indent: ${config.textIndent}px; -webkit-font-smoothing: ${config.fontSmoothing}; -moz-osx-font-smoothing: ${config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'}; `); if (config.hyphenate) { enableHyphenation(container, content as HTMLElement); } setLigatures(content as HTMLElement, config.ligatures); if (config.textAlign === 'justify') { enableJustification(content as HTMLElement); } } function enableHyphenation(container: HTMLElement, element: HTMLElement): void { element.style.hyphens = 'auto'; element.style.hyphenateLimitChars = '6 3 3'; const lang = container.closest('[data-language]')?.getAttribute('data-language') || 'en'; element.setAttribute('lang', lang); } function setLigatures(element: HTMLElement, enabled: boolean): void { if (enabled) { element.style.fontVariantLigatures = 'common-ligatures'; element.style.fontFeatureSettings = '"liga", "dlig"'; } else { element.style.fontVariantLigatures = 'no-common-ligatures'; element.style.fontFeatureSettings = 'normal'; } } function enableJustification(element: HTMLElement): void { element.style.wordBreak = 'normal'; element.style.overflowWrap = 'break-word'; element.style.wordWrap = 'break-word'; element.style.letterSpacing = '0.01em'; } function updateTypographyConfig( currentConfig: TypographyConfig, newConfig: Partial ): TypographyConfig { return { ...currentConfig, ...newConfig }; } function measureReadingTime(container: HTMLElement, wordsPerMinute: number = 250): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; const words = text.split(/\s+/).length; const minutes = words / wordsPerMinute; return Math.ceil(minutes); } function getWordCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content'); if (!content) return 0; const text = content.textContent || ''; return text.split(/\s+/).length; } ``` ### 5.12 Search Within Ebook **File:** `web/src/reader/ebook/search.ts` ```typescript // Search within ebook content interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } // Search within ebook content // Procedural implementation (no OOP) interface SearchResult { cfi: string; snippet: string; chapterTitle: string; } async function searchEbook( epubPackage: EPUBPackage, query: string ): Promise { const results: SearchResult[] = []; const lowerQuery = query.toLowerCase(); for (const [index, spineItem] of epubPackage.spine.entries()) { const doc = await getSpineItemDocument(epubPackage, spineItem); if (!doc) continue; const chapterTitle = getChapterTitle(epubPackage, spineItem); const textNodes = findTextNodes(doc.body); for (const node of textNodes) { const text = node.textContent || ''; const lowerText = text.toLowerCase(); let foundAt = 0; while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { const cfi = generateSearchCFI(node, foundAt); const snippet = extractSearchSnippet(text, foundAt, query.length); results.push({ cfi, snippet, chapterTitle }); foundAt += lowerQuery.length; } } } return results; } async function getSpineItemDocument( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): Promise { try { const content = await epubPackage.resources.get(spineItem.href)?.text(); if (!content) return null; const parser = new DOMParser(); return parser.parseFromString(content, 'text/html'); } catch (error) { console.error('Failed to load spine item:', spineItem.href, error); return null; } } function findTextNodes(root: Node): Text[] { const textNodes: Text[] = []; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { const parent = node.parentElement; if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) { return NodeFilter.FILTER_REJECT; } if (!node.textContent?.trim()) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); let node: Node | null; while ((node = walker.nextNode())) { textNodes.push(node as Text); } return textNodes; } function generateSearchCFI(node: Text, offset: number): string { const path: number[] = []; let current: Node | null = node; while (current && current.parentNode) { const parent = current.parentNode; const siblings = Array.from(parent.childNodes) .filter(n => n.nodeType === Node.ELEMENT_NODE); const index = siblings.indexOf(current as Node); path.unshift(index); current = parent; } const spineIndex = 0; return generateCFI(spineIndex, path, offset); } function extractSearchSnippet(text: string, offset: number, length: number): string { const contextBefore = 30; const contextAfter = 50; const start = Math.max(0, offset - contextBefore); const end = Math.min(text.length, offset + length + contextAfter); let snippet = text.substring(start, end); if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; return snippet; } function getChapterTitle( epubPackage: EPUBPackage, spineItem: EPUBSpineItem ): string { for (const toc of epubPackage.toc) { if (toc.href === spineItem.href) { return toc.label; } for (const child of toc.children) { if (child.href === spineItem.href) { return child.label; } } } return 'Chapter ' + (epubPackage.spine.indexOf(spineItem) + 1); } ``` ### 5.13 Copy Text Handler **File:** `web/src/reader/ebook/copy-handler.ts` ```typescript // Handle text copying with citation // Handle text copying with citation // Procedural implementation (no OOP) async function copySelection(mediaItem: MediaItemSummary): Promise { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const selectedText = selection.toString(); if (!selectedText.trim()) return false; const citation = createCitation(selectedText, mediaItem); try { await navigator.clipboard.writeText(citation); showToast('Copied to clipboard', 'success'); return true; } catch (error) { console.error('Failed to copy:', error); showToast('Failed to copy to clipboard', 'error'); return false; } } function createCitation(text: string, mediaItem: MediaItemSummary): string { let citation = `"${text}"\n`; citation += `β€” ${mediaItem.title}`; if (mediaItem.author) { citation += ` by ${mediaItem.author}`; } citation += `\n(Source: Bookhoard)`; return citation; } function enableContextMenuCopy(mediaItem: MediaItemSummary): void { document.addEventListener('contextmenu', async (e) => { const selection = window.getSelection(); const selectedText = selection?.toString().trim(); if (selectedText) { e.preventDefault(); await copySelection(mediaItem); } }); } ``` ### 5.14 View Modes **File:** `web/src/reader/ebook/view-modes.ts` ```typescript // Different viewing modes for ebooks type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; // Different viewing modes for ebooks // Procedural implementation (no OOP) type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column'; interface ViewModeState { currentMode: ViewMode; currentPage: number; } function setViewMode(container: HTMLElement, mode: ViewMode): void { const content = container.querySelector('.ebook-content'); if (!content) return; content.classList.remove( 'paginated', 'scrolled', 'single-column', 'double-column' ); switch (mode) { case 'paginated': applyPaginatedMode(container, content as HTMLElement); break; case 'scrolled': applyScrolledMode(container, content as HTMLElement); break; case 'single-column': applySingleColumn(content as HTMLElement); break; case 'double-column': applyDoubleColumn(content as HTMLElement); break; } } function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void { element.classList.add('paginated'); element.style.height = '100vh'; element.style.overflow = 'hidden'; element.style.columnCount = '1'; element.style.columnGap = '0'; enablePagination(container, element); } function applyScrolledMode(container: HTMLElement, element: HTMLElement): void { element.classList.add('scrolled'); element.style.height = 'auto'; element.style.overflowY = 'auto'; element.style.columnCount = '1'; disablePagination(container); } function applySingleColumn(element: HTMLElement): void { element.classList.add('single-column'); element.style.columnCount = '1'; element.style.columnGap = '0'; element.style.maxWidth = '800px'; element.style.margin = '0 auto'; } function applyDoubleColumn(element: HTMLElement): void { element.classList.add('double-column'); element.style.columnCount = '2'; element.style.columnGap = '60px'; element.style.columnRule = '1px solid var(--text-secondary)'; element.style.maxWidth = '1400px'; element.style.margin = '0 auto'; } function enablePagination(container: HTMLElement, element: HTMLElement): void { const totalHeight = element.scrollHeight; const pageHeight = element.clientHeight; const pageCount = Math.ceil(totalHeight / pageHeight); addPaginationControls(container, pageCount); } function disablePagination(container: HTMLElement): void { const controls = container.querySelector('.pagination-controls'); controls?.remove(); } function addPaginationControls(container: HTMLElement, pageCount: number): ViewModeState { let currentPage = 1; const controls = document.createElement('div'); controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t'; controls.innerHTML = ` Page ${currentPage} of ${pageCount} `; controls.querySelector('.prev-page')?.addEventListener('click', () => { if (currentPage > 1) { currentPage--; goToPage(container, currentPage); } }); controls.querySelector('.next-page')?.addEventListener('click', () => { if (currentPage < pageCount) { currentPage++; goToPage(container, currentPage); } }); container.appendChild(controls); return { currentMode: 'paginated', currentPage }; } function goToPage(container: HTMLElement, pageNumber: number): void { const content = container.querySelector('.ebook-content') as HTMLElement; if (!content) return; const pageHeight = content.clientHeight; const scrollTop = (pageNumber - 1) * pageHeight; content.scrollTo({ top: scrollTop, behavior: 'smooth' }); const pageInfo = container.querySelector('.page-info'); if (pageInfo) { pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`; } } function getTotalPageCount(container: HTMLElement): number { const content = container.querySelector('.ebook-content') as HTMLElement; if (!content) return 1; const totalHeight = content.scrollHeight; const pageHeight = content.clientHeight; return Math.ceil(totalHeight / pageHeight); } ``` --- ## 6. PDF Reader Implementation ### 6.1 PDF.js Integration (Procedural) **File:** `web/src/reader/pdf/pdfjs-wrapper.ts` ```typescript // Mozilla pdf.js integration for PDF rendering // Procedural style: Functions, not classes import * as pdfjsLib from 'pdfjs-dist'; // ============================================================ // PDF.js Configuration // ============================================================ export function configurePDFJS(): void { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.mjs'; pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = '/static/standard_fonts/'; pdfjsLib.GlobalWorkerOptions.cMapUrl = '/static/cmaps/'; pdfjsLib.GlobalWorkerOptions.cMapPacked = true; } // ============================================================ // PDF Document State // ============================================================ interface PDFDocumentState { doc: pdfjsLib.PDFDocumentProxy | null; pages: Map; metadata: PDFMetadata | null; } interface PDFMetadata { title: string; author: string; subject?: string; keywords?: string; creator?: string; producer?: string; creationDate?: Date; modificationDate?: Date; pageCount: number; } let pdfState: PDFDocumentState = { doc: null, pages: new Map(), metadata: null, }; // ============================================================ // Document Loading // ============================================================ export async function loadPDFDocument(pdfBlob: Blob): Promise { // Cleanup previous document unloadPDFDocument(); const arrayBuffer = await pdfBlob.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer, }); pdfState.doc = await loadingTask.promise; // Extract metadata const metadata = await pdfState.doc.getMetadata().catch(() => null); const info = metadata?.info || {}; pdfState.metadata = { title: info.Title || 'Untitled', author: info.Author || 'Unknown', subject: info.Subject, keywords: info.Keywords, creator: info.Creator, producer: info.Producer, creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined, modificationDate: info.ModDate ? new Date(info.ModDate) : undefined, pageCount: pdfState.doc.numPages, }; return pdfState.metadata; } export async function getPDFPage(pageNumber: number): Promise { if (!pdfState.doc) { throw new Error('PDF document not loaded'); } // Check cache if (pdfState.pages.has(pageNumber)) { return pdfState.pages.get(pageNumber)!; } // Load page const page = await pdfState.doc.getPage(pageNumber); pdfState.pages.set(pageNumber, page); return page; } export async function getPDFPageText(pageNumber: number): Promise { const page = await getPDFPage(pageNumber); return await page.getTextContent(); } export function getPDFMetadata(): PDFMetadata | null { return pdfState.metadata; } export function getPDFPageCount(): number { return pdfState.doc?.numPages || 0; } export function unloadPDFDocument(): void { pdfState.pages.clear(); pdfState.doc = null; pdfState.metadata = null; } export function unloadPDFPage(pageNumber: number): void { pdfState.pages.delete(pageNumber); } ``` --- ### 6.2 Text Layer Renderer (Procedural) **File:** `web/src/reader/pdf/text-layer-renderer.ts` ```typescript // Text layer rendering for PDF text selection and highlighting // Procedural style: Functions, not classes // ============================================================ // Render Functions // ============================================================ export function renderTextLayer( container: HTMLElement, viewport: any, textContent: any, config: TextLayerConfig ): void { // Clear container container.innerHTML = ''; // Apply styles applyTextLayerStyles(container, config); // Render text items const { items } = textContent; items.forEach((item: any, index: number) => { if (typeof item === 'string') return; const textDiv = createTextDiv(item, viewport, index); container.appendChild(textDiv); }); } function createTextDiv(item: any, viewport: any, index: number): HTMLElement { const div = document.createElement('div'); div.className = 'pdf-text-layer-text'; div.textContent = item.str; div.dataset.index = index.toString(); // Position the text div const tx = pdfjsLib.Util.transform( viewport.transform, item.transform ); const fontSize = Math.sqrt((tx[0] * tx[0]) + (tx[1] * tx[1])); div.style.left = `${tx[4]}px`; div.style.top = `${tx[5] - fontSize}px`; div.style.fontSize = `${fontSize}px`; div.style.fontFamily = item.fontName || 'sans-serif'; // Handle text direction if (item.dir === 'ttb') { div.style.writingMode = 'vertical-rl'; } return div; } interface TextLayerConfig { theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast'; } function applyTextLayerStyles(container: HTMLElement, config: TextLayerConfig): void { const style = document.createElement('style'); style.textContent = getTextLayerCSS(config.theme); container.appendChild(style); } function getTextLayerCSS(theme: string): string { const colors = getThemeColors(theme); return ` .pdf-text-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; opacity: 1; line-height: 1; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; user-select: none; } .pdf-text-layer-text { position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; color: transparent; pointer-events: auto; } .pdf-text-layer-text::selection { background: ${colors.highlight}; color: transparent; } .pdf-text-layer-text::-moz-selection { background: ${colors.highlight}; color: transparent; } .pdf-highlight-overlay { position: absolute; background-color: ${colors.highlight}; mix-blend-mode: multiply; pointer-events: none; } `; } function getThemeColors(theme: string): { highlight: string } { const themes: Record = { 'light': { highlight: 'rgba(255, 255, 0, 0.3)' }, 'sepia': { highlight: 'rgba(255, 200, 0, 0.4)' }, 'dark': { highlight: 'rgba(255, 255, 0, 0.3)' }, 'night': { highlight: 'rgba(100, 150, 255, 0.3)' }, 'high-contrast': { highlight: 'rgba(255, 255, 0, 0.5)' } }; return themes[theme] || themes['dark']; } // ============================================================ // Selection Functions // ============================================================ export function getPDFTextSelection(): { text: string; range: Range } | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const text = range.toString(); if (!text) return null; return { text, range }; } export function getPDFSelectionRects(): DOMRect[] { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return []; const rects: DOMRect[] = []; const range = selection.getRangeAt(0); for (const rect of range.getClientRects()) { rects.push(rect); } return rects; } ``` --- ### 6.3 Annotation Layer (Procedural) **File:** `web/src/reader/pdf/annotation-layer.ts` ```typescript // Annotation layer for rendering highlights and notes on PDFs // Procedural style: Functions, not classes interface PDFHighlight { id: string; pageNumber: number; rects: DOMRect[]; text: string; color: string; noteId?: string; } const highlights = new Map(); export function renderPDFHighlights( container: HTMLElement, highlightList: PDFHighlight[] ): void { // Clear existing highlights clearPDFHighlights(container); for (const highlight of highlightList) { renderSinglePDFHighlight(container, highlight); } } function renderSinglePDFHighlight(container: HTMLElement, highlight: PDFHighlight): void { const overlay = document.createElement('div'); overlay.className = 'pdf-highlight-annotation'; overlay.dataset.highlightId = highlight.id; overlay.style.backgroundColor = parseColor(highlight.color); // Position highlight rectangles for (const rect of highlight.rects) { const rectDiv = document.createElement('div'); rectDiv.className = 'pdf-highlight-rect'; rectDiv.style.left = `${rect.left}px`; rectDiv.style.top = `${rect.top}px`; rectDiv.style.width = `${rect.width}px`; rectDiv.style.height = `${rect.height}px`; overlay.appendChild(rectDiv); } // Add click handler for note popup if (highlight.noteId) { overlay.style.cursor = 'pointer'; overlay.addEventListener('click', () => { showNotePopup(highlight); }); } // Add hover effect overlay.addEventListener('mouseenter', () => { overlay.style.opacity = '0.8'; }); overlay.addEventListener('mouseleave', () => { overlay.style.opacity = '0.5'; }); container.appendChild(overlay); highlights.set(highlight.id, overlay); } function parseColor(color: string): string { if (color.startsWith('#')) { const hex = color.slice(1); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, 0.4)`; } return color; } function showNotePopup(highlight: PDFHighlight): void { console.log('Show note for highlight:', highlight.id); } export function clearPDFHighlights(container: HTMLElement): void { highlights.forEach(element => element.remove()); highlights.clear(); } export function removePDFHighlight(highlightId: string): void { const element = highlights.get(highlightId); if (element) { element.remove(); highlights.delete(highlightId); } } ``` --- ### 6.4 PDF Navigation (Procedural) **File:** `web/src/reader/pdf/pdf-navigation.ts` ```typescript // PDF navigation: page turning, zoom, fit modes // Procedural style: Functions, not classes type PageFitMode = 'fit-width' | 'fit-page' | 'fit-height' | 'none'; interface PDFNavigationState { currentPage: number; totalPages: number; currentScale: number; fitMode: PageFitMode; scrollContainer: HTMLElement | null; } let navState: PDFNavigationState = { currentPage: 1, totalPages: 0, currentScale: 1.0, fitMode: 'fit-width', scrollContainer: null, }; // ============================================================ // Initialization // ============================================================ export function initializePDFNavigation( container: HTMLElement, onPageChange: (pageNumber: number) => void, onZoomChange: (scale: number) => void ): void { navState.scrollContainer = container.querySelector('.pdf-scroll-container') || container; setupPDFKeyboardNav(onPageChange); setupPDFScrollTracking(onPageChange); } export function setPDFTotalPages(totalPages: number): void { navState.totalPages = totalPages; } // ============================================================ // Page Navigation // ============================================================ export function goToPDFPage(pageNumber: number): void { if (pageNumber < 1 || pageNumber > navState.totalPages) return; navState.currentPage = pageNumber; const callback = (window as any).pdfOnPageChange; if (callback) callback(pageNumber); scrollToPDFPage(pageNumber); } export function nextPDFPage(): void { if (navState.currentPage < navState.totalPages) { goToPDFPage(navState.currentPage + 1); } } export function previousPDFPage(): void { if (navState.currentPage > 1) { goToPDFPage(navState.currentPage - 1); } } function scrollToPDFPage(pageNumber: number): void { if (!navState.scrollContainer) return; const pageElement = navState.scrollContainer.querySelector(`[data-page-number="${pageNumber}"]`); if (pageElement) { pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } // ============================================================ // Zoom Controls // ============================================================ export function setPDFZoom(scale: number): void { navState.currentScale = scale; navState.fitMode = 'none'; const callback = (window as any).pdfOnZoomChange; if (callback) callback(scale); updatePDFZoom(); } export function setPDFFitMode(mode: PageFitMode): void { navState.fitMode = mode; updatePDFZoom(); } export function zoomPDFIn(): void { setPDFZoom(navState.currentScale * 1.2); } export function zoomPDFOut(): void { setPDFZoom(navState.currentScale / 1.2); } function updatePDFZoom(): void { if (!navState.scrollContainer) return; const pages = navState.scrollContainer.querySelectorAll('.pdf-page-container'); pages.forEach((page: Element) => { (page as HTMLElement).style.transform = `scale(${navState.currentScale})`; (page as HTMLElement).style.transformOrigin = 'top center'; }); } // ============================================================ // Keyboard Navigation // ============================================================ function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void { document.addEventListener('keydown', handlePDFKeyDown); } function handlePDFKeyDown(e: KeyboardEvent): void { switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); nextPDFPage(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); previousPDFPage(); break; case 'Home': e.preventDefault(); goToPDFPage(1); break; case 'End': e.preventDefault(); goToPDFPage(navState.totalPages); break; } } // ============================================================ // Scroll Tracking // ============================================================ function setupPDFScrollTracking(onPageChange: (pageNumber: number) => void): void { if (!navState.scrollContainer) return; let scrollTimeout: NodeJS.Timeout; navState.scrollContainer.addEventListener('scroll', () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { updateCurrentPageFromScroll(onPageChange); }, 100); }); } function updateCurrentPageFromScroll(onPageChange: (pageNumber: number) => void): void { if (!navState.scrollContainer) return; const scrollTop = navState.scrollContainer.scrollTop; const containerHeight = navState.scrollContainer.clientHeight; const pages = navState.scrollContainer.querySelectorAll('[data-page-number]'); let maxVisibility = 0; let mostVisiblePage = navState.currentPage; pages.forEach((page) => { const element = page as HTMLElement; const pageTop = element.offsetTop; const pageBottom = pageTop + element.offsetHeight; const visibleTop = Math.max(scrollTop, pageTop); const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom); const visibleHeight = Math.max(0, visibleBottom - visibleTop); if (visibleHeight > maxVisibility) { maxVisibility = visibleHeight; mostVisiblePage = parseInt(element.dataset.pageNumber || '1'); } }); if (mostVisiblePage !== navState.currentPage) { navState.currentPage = mostVisiblePage; onPageChange(mostVisiblePage); } } // ============================================================ // Getters // ============================================================ export function getCurrentPDFPage(): number { return navState.currentPage; } export function getTotalPDFPages(): number { return navState.totalPages; } export function getPDFScale(): number { return navState.currentScale; } ``` ### 6.5 PDF Search **File:** `web/src/reader/pdf/pdf-search.ts` ```typescript // Full-text search within PDF documents import { PDFDocumentProxy } from 'pdfjs-dist'; interface SearchResult { pageNumber: number; text: string; index: number; context: string; } // Full-text search within PDF documents // Procedural implementation (no OOP) interface SearchResult { pageNumber: number; text: string; index: number; context: string; } interface PDFSearchState { doc: PDFDocumentProxy | null; searchResults: SearchResult[]; currentResultIndex: number; } async function initializePDFSearch(doc: PDFDocumentProxy): Promise { return { doc, searchResults: [], currentResultIndex: 0 }; } async function searchPDF(state: PDFSearchState, query: string): Promise { if (!state.doc) return state; const searchResults: SearchResult[] = []; const lowerQuery = query.toLowerCase(); for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { const page = await state.doc.getPage(pageNum); const textContent = await page.getTextContent(); let fullText = ''; const textItems = textContent.items.map(item => { if (typeof item === 'string') return ''; fullText += item.str; return item.str; }); const pageText = textItems.join(' '); const matches = findSearchMatches(pageText, lowerQuery, pageNum); searchResults.push(...matches); } return { ...state, searchResults }; } function findSearchMatches( text: string, query: string, pageNumber: number ): SearchResult[] { const matches: SearchResult[] = []; const lowerText = text.toLowerCase(); let index = 0; while ((index = lowerText.indexOf(query, index)) !== -1) { const start = Math.max(0, index - 50); const end = Math.min(text.length, index + query.length + 50); const context = text.slice(start, end); matches.push({ pageNumber, text: text.slice(index, index + query.length), index, context }); index += query.length; } return matches; } function goToNextSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { if (state.searchResults.length === 0) { return { ...state, result: null }; } const newIndex = (state.currentResultIndex + 1) % state.searchResults.length; return { ...state, currentResultIndex: newIndex, result: state.searchResults[newIndex] }; } function goToPreviousSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } { if (state.searchResults.length === 0) { return { ...state, result: null }; } const newIndex = (state.currentResultIndex - 1 + state.searchResults.length) % state.searchResults.length; return { ...state, currentResultIndex: newIndex, result: state.searchResults[newIndex] }; } function getSearchResultCount(state: PDFSearchState): number { return state.searchResults.length; } function clearSearchResults(state: PDFSearchState): PDFSearchState { return { ...state, searchResults: [], currentResultIndex: 0 }; } ``` ### 6.6 Page Cache (Pre-rendering) **File:** `web/src/reader/pdf/page-cache.ts` ```typescript // 5-page ahead cache for PDF pages // Pre-renders canvas and text layer for nearby pages import { PDFPageProxy, PageViewport } from 'pdfjs-dist'; interface CachedPage { pageNumber: number; canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport; timestamp: number; } // 5-page ahead cache for PDF pages // Procedural implementation (no OOP) interface CachedPage { pageNumber: number; canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport; timestamp: number; } interface PDFPageCacheState { cache: Map; maxCacheSize: number; renderCallbacks: Map void>>; } function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState { return { cache: new Map(), maxCacheSize, renderCallbacks: new Map() }; } async function getCachedPage( state: PDFPageCacheState, pageNumber: number, renderFn: (pageNumber: number) => Promise<{ canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport }> ): Promise { const cached = state.cache.get(pageNumber); if (cached) { cached.timestamp = Date.now(); return { ...state, page: cached }; } const { canvas, textLayer, viewport } = await renderFn(pageNumber); const cachedPage: CachedPage = { pageNumber, canvas, textLayer, viewport, timestamp: Date.now() }; const newCache = new Map(state.cache); newCache.set(pageNumber, cachedPage); const callbacks = state.renderCallbacks.get(pageNumber); if (callbacks) { callbacks.forEach(cb => cb()); const newCallbacks = new Map(state.renderCallbacks); newCallbacks.delete(pageNumber); return { ...state, cache: newCache, renderCallbacks: newCallbacks, page: cachedPage }; } return { ...state, cache: newCache, page: cachedPage }; } function preloadPages( state: PDFPageCacheState, currentPage: number, totalPages: number ): PDFPageCacheState { for (let i = 1; i <= state.maxCacheSize; i++) { const pageNumber = currentPage + i; if (pageNumber <= totalPages && !state.cache.has(pageNumber)) { triggerPreload(pageNumber); } } return state; } function triggerPreload(pageNumber: number): void { console.log('Preloading page:', pageNumber); } function invalidatePage( state: PDFPageCacheState, pageNumber: number ): PDFPageCacheState { const cached = state.cache.get(pageNumber); if (cached) { cached.canvas.remove(); cached.textLayer.remove(); const newCache = new Map(state.cache); newCache.delete(pageNumber); return { ...state, cache: newCache }; } return state; } function clearPageCache(state: PDFPageCacheState): PDFPageCacheState { state.cache.forEach(page => { page.canvas.remove(); page.textLayer.remove(); }); return { ...state, cache: new Map() }; } function onPageRendered( state: PDFPageCacheState, pageNumber: number, callback: () => void ): PDFPageCacheState { const newCallbacks = new Map(state.renderCallbacks); if (!newCallbacks.has(pageNumber)) { newCallbacks.set(pageNumber, []); } newCallbacks.get(pageNumber)!.push(callback); return { ...state, renderCallbacks: newCallbacks }; } ``` ### 6.7 PDF Text Selection (Uses Backend API) **File:** `web/src/reader/pdf/pdf-text-selection.ts` ```typescript // PDF text selection - Uses backend API for highlight creation // Backend handles all position calculations for PDFs // Procedural style: Functions, not classes interface PDFTextSelection { pageNumber: number; text: string; rects: DOMRect[]; } // ============================================================ // Get PDF Text Selection // ============================================================ export function getPDFTextSelection(): PDFTextSelection | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const text = range.toString(); if (!text) return null; // Get page number from selection const pageElement = range.commonAncestorContainer.closest?.('[data-page-number]'); const pageNumber = pageElement?.dataset.pageNumber ? parseInt(pageElement.dataset.pageNumber) : getCurrentPDFPage(); // Get bounding rectangles const rects: DOMRect[] = []; for (const rect of range.getClientRects()) { rects.push(rect); } return { pageNumber, text, rects }; } // ============================================================ // Create PDF Highlight (Backend Calculates Position) // ============================================================ export async function createPDFHighlight( mediaItemId: string, selection: PDFTextSelection, color: string ): Promise { const selectionData = { selection_text: selection.text, page_number: selection.pageNumber, rects: selection.rects.map(rect => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })), color }; // Send to backend - backend calculates all position formats const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(selectionData) }); if (!response.ok) { throw new Error(`Failed to create highlight: ${response.statusText}`); } return await response.json(); } // ============================================================ // Load and Render PDF Highlights (Backend Provides Positions) // ============================================================ export async function loadAndRenderPDFHighlights( mediaItemId: string, container: HTMLElement ): Promise { const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); if (!response.ok) return []; const highlights: Highlight[] = await response.json(); for (const highlight of highlights) { renderPDFHighlight(container, highlight); } } function renderPDFHighlight(container: HTMLElement, highlight: Highlight): void { // Backend provides position data for PDF highlights // Check which position format is available if (highlight.start_position && highlight.start_position.startsWith('pdf:page:')) { // Backend calculated page-based position renderPDFHighlightByPosition(container, highlight); } else if (highlight.percentage_start !== null) { // Backend calculated percentage position renderPDFHighlightByPercentage(container, highlight); } } function renderPDFHighlightByPosition(container: HTMLElement, highlight: Highlight): void { // Parse position string: "pdf:page:45:offset:123" const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/); if (!match) return; const pageNumber = parseInt(match[1], 10); const offset = parseInt(match[2], 10); // Find the page element const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`); if (!pageElement) return; // Get text content at offset const textContent = pageElement.querySelector('.pdf-text-layer')?.textContent; if (!textContent) return; // Find the text at this offset const textBefore = textContent.substring(0, offset); const startChar = textBefore.length; const endChar = startChar + (highlight.selection_text?.length || 10); if (startChar < textContent.length && endChar <= textContent.length) { applyHighlightToTextContent( pageElement as HTMLElement, startChar, endChar, highlight.color ); } } function renderPDFHighlightByPercentage(container: HTMLElement, highlight: Highlight): void { // Backend provides percentage - estimate position const percentage = highlight.percentage_start || 0; // Find spine item closest to this percentage const totalPages = container.querySelectorAll('[data-page-number]').length; const targetPage = Math.ceil(percentage * totalPages); const pageElement = container.querySelector(`[data-page-number="${targetPage}"]`); if (pageElement) { // Highlight entire page (coarse-grained) applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color); } } function applyHighlightToTextContent( element: HTMLElement, startChar: number, endChar: number, color: string ): void { const text = element.textContent || ''; const before = text.substring(0, startChar); const selection = text.substring(startChar, endChar); const after = text.substring(endChar); element.textContent = before + selection + after; // Use a mark to wrap the selected text element.innerHTML = `${before}${selection}${after}`; } ``` --- ### 6.8 PDF Outline/TOC Navigation **File:** `web/src/reader/pdf/pdf-outline.ts` ```typescript // PDF outline/TOC navigation // Procedural implementation (no OOP) interface PDFOutlineNode { id: string; title: string; destination: number | null; pageNumber?: number; children: PDFOutlineNode[]; expanded: boolean; } interface PDFOutlineState { doc: PDFDocumentProxy | null; outline: PDFOutlineNode[]; flatMap: Map; } async function initializePDFOutline(doc: PDFDocumentProxy): Promise { const state: PDFOutlineState = { doc, outline: [], flatMap: new Map() }; return await loadPDFOutline(state); } async function loadPDFOutline(state: PDFOutlineState): Promise { if (!state.doc) return state; const pdfOutline = await state.doc.getOutline(); if (!pdfOutline || pdfOutline.length === 0) { return { ...state, outline: [] }; } const outline = await parseOutlineNodes(state, pdfOutline); return { ...state, outline }; } async function parseOutlineNodes( state: PDFOutlineState, nodes: OutlineTreeNode[] ): Promise { const result: PDFOutlineNode[] = []; for (const node of nodes) { const outlineNode: PDFOutlineNode = { id: generateOutlineId(), title: node.title, destination: null, children: [], expanded: false }; if (node.dest) { const pageNumber = await resolvePDFDestination(state, node.dest); outlineNode.destination = pageNumber; outlineNode.pageNumber = pageNumber; state.flatMap.set(node.title, pageNumber); } if (node.items && node.items.length > 0) { outlineNode.children = await parseOutlineNodes(state, node.items); } result.push(outlineNode); } return result; } async function resolvePDFDestination( state: PDFOutlineState, dest: string | any[] ): Promise { if (!state.doc) return 1; try { let explicitDest: any[]; if (typeof dest === 'string') { const destObj = await state.doc.getDestination(dest); if (!destObj) return 1; explicitDest = destObj; } else { explicitDest = dest; } const ref = explicitDest[0]; if (typeof ref === 'object' && ref !== null) { const pageIndex = await state.doc.getPageIndex(ref); return pageIndex + 1; } else if (typeof ref === 'number') { return ref + 1; } return 1; } catch (error) { console.error('Failed to resolve destination:', dest, error); return 1; } } function generateOutlineId(): string { return `outline-${Math.random().toString(36).substr(2, 9)}`; } function getOutline(state: PDFOutlineState): PDFOutlineNode[] { return state.outline; } function getOutlineFlatMap(state: PDFOutlineState): Map { return state.flatMap; } function getCurrentChapter( state: PDFOutlineState, pageNumber: number ): PDFOutlineNode | null { return findChapterForPage(state.outline, pageNumber); } function findChapterForPage( nodes: PDFOutlineNode[], pageNumber: number ): PDFOutlineNode | null { for (const node of nodes) { if (node.pageNumber && node.pageNumber <= pageNumber) { if (node.children.length > 0) { const childMatch = findChapterForPage(node.children, pageNumber); if (childMatch) return childMatch; } return node; } if (node.children.length > 0) { const childMatch = findChapterForPage(node.children, pageNumber); if (childMatch) return childMatch; } } return null; } function toggleOutlineNode( state: PDFOutlineState, nodeId: string ): PDFOutlineState { const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => { return nodes.map(node => { if (node.id === nodeId) { return { ...node, expanded: !node.expanded }; } if (node.children.length > 0) { return { ...node, children: updateNode(node.children) }; } return node; }); }; return { ...state, outline: updateNode(state.outline) }; } function findOutlineNode( nodes: PDFOutlineNode[], id: string ): PDFOutlineNode | null { for (const node of nodes) { if (node.id === id) return node; if (node.children.length > 0) { const found = findOutlineNode(node.children, id); if (found) return found; } } return null; } ``` ### 6.9 PDF Bookmarks **File:** `web/src/reader/pdf/pdf-bookmarks.ts` ```typescript // Custom bookmarks for PDF pages (saved in database) // Procedural implementation (no OOP) interface MediaBookmark { id: string; mediaItemId: string; userId: string; pageNumber: number; title: string; createdAt: string; } interface MediaBookmarksState { mediaItemId: string; bookmarks: MediaBookmark[]; } function createMediaBookmarks(mediaItemId: string): MediaBookmarksState { return { mediaItemId, bookmarks: [] }; } async function loadMediaBookmarks(state: MediaBookmarksState): 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 addMediaBookmark( state: MediaBookmarksState, pageNumber: number, title?: string ): Promise { const bookmark: MediaBookmark = { 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 removeMediaBookmark( state: MediaBookmarksState, 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 getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] { return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber); } function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean { return state.bookmarks.some(b => b.pageNumber === pageNumber); } function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | 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 Navigator Panel (Affinity Image Editor Style) **File:** `web/src/reader/navigator-panel.ts` ```typescript // Navigator panel - shows full page with draggable viewport box // Affinity/Photoshop-style mini-map for page navigation // Procedural implementation (no OOP) import { loadSettings } from "./settings-manager"; interface NavigatorState { panelId: string; container: HTMLElement; viewport: HTMLElement; currentPage: number; totalPages: number; scale: number; contentImage: HTMLImageElement | null; isDragging: boolean; } function initializeNavigator(containerSelector: string): NavigatorState { const container = document.querySelector(containerSelector) as HTMLElement; if (!container) throw new Error("Navigator container not found"); const viewport = document.createElement('div'); viewport.className = 'navigator-viewport-box'; viewport.style.cssText = ` position: absolute; border: 2px solid var(--accent-color, #3b82f6); background: rgba(59, 130, 246, 0.1); cursor: move; z-index: 10; `; container.appendChild(viewport); const state: NavigatorState = { panelId: 'navigator', container, viewport, currentPage: 1, totalPages: 1, scale: 0.1, contentImage: null, isDragging: false }; setupNavigatorDragHandler(state); return state; } // Setup draggable viewport box within navigator function setupNavigatorDragHandler(state: NavigatorState): void { state.viewport.addEventListener('mousedown', (e) => { e.preventDefault(); state.isDragging = true; state.viewport.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!state.isDragging || !state.contentImage) return; const containerRect = state.container.getBoundingClientRect(); const imgRect = state.contentImage.getBoundingClientRect(); // Calculate position relative to scaled image const relX = (e.clientX - imgRect.left) / imgRect.width; const relY = (e.clientY - imgRect.top) / imgRect.height; // Update main viewer's position (call external handler) const mainViewer = document.getElementById('reader-content'); if (mainViewer) { mainViewer.dataset.panX = relX.toString(); mainViewer.dataset.panY = relY.toString(); // Dispatch event for main viewer to handle mainViewer.dispatchEvent(new CustomEvent('navigator-pan', { detail: { x: relX, y: relY } })); } }); document.addEventListener('mouseup', () => { state.isDragging = false; state.viewport.style.cursor = 'move'; }); } // Update navigator with current page image async function updateNavigatorContent(state: NavigatorState, pageNumber: number): Promise { state.currentPage = pageNumber; // Get current page image (from PDF viewer, comic reader, or manga reader) const contentArea = document.getElementById('reader-content'); const img = contentArea?.querySelector('img, canvas') as HTMLImageElement | HTMLCanvasElement | null; if (!img) return; // Create thumbnail version for navigator const thumb = document.createElement('img'); thumb.src = img.src || (img as HTMLCanvasElement).toDataURL(); thumb.style.cssText = ` width: 100%; height: auto; display: block; pointer-events: none; `; // Clear and populate container state.container.innerHTML = ''; state.container.appendChild(thumb); state.contentImage = thumb; // Recreate viewport box const viewport = document.createElement('div'); viewport.className = 'navigator-viewport-box'; viewport.style.cssText = ` position: absolute; border: 2px solid var(--accent-color, #3b82f6); background: rgba(59, 130, 246, 0.1); cursor: move; z-index: 10; width: ${100 / state.scale}%; height: ${100 / state.scale}%; `; state.container.appendChild(viewport); state.viewport = viewport; // Re-attach drag handler setupNavigatorDragHandler(state); // Calculate viewport size relative to container const containerRect = state.container.getBoundingClientRect(); const viewportWidth = (containerRect.width / img.width) * 100; const viewportHeight = (containerRect.height / img.height) * 100; viewport.style.width = `${viewportWidth}%`; viewport.style.height = `${viewportHeight}%`; } // Handle window resize function handleNavigatorResize(state: NavigatorState): void { if (state.contentImage) { updateNavigatorContent(state, state.currentPage); } } export { initializeNavigator, updateNavigatorContent, handleNavigatorResize }; ``` **File:** `web/src/reader/pdf/pdf-rotation.ts` ```typescript // Handle rotated/landscape pages in PDFs // Procedural implementation (no OOP) interface PDFRotationState { rotations: Map; } function createPDFRotation(): PDFRotationState { return { rotations: new Map() }; } async function loadPDFPageRotations( state: PDFRotationState, doc: any ): Promise { const rotations = new Map(); for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale: 1 }); const rotation = viewport.rotation; if (rotation !== 0) { rotations.set(pageNum, rotation); } } return { ...state, rotations }; } function getPDFPageRotation(state: PDFRotationState, pageNumber: number): number { return state.rotations.get(pageNumber) || 0; } function hasPDFPageRotation(state: PDFRotationState, pageNumber: number): boolean { return state.rotations.has(pageNumber); } function applyPDFRotation( state: PDFRotationState, canvas: HTMLCanvasElement, pageNumber: number ): void { const rotation = getPDFPageRotation(state, pageNumber); if (rotation === 0) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((rotation * Math.PI) / 180); ctx.translate(-canvas.width / 2, -canvas.height / 2); ctx.restore(); } function getPDFAdjustedViewport( state: PDFRotationState, pageNumber: number, viewport: any ): any { const rotation = getPDFPageRotation(state, pageNumber); if (rotation === 0 || rotation === 180) { return viewport; } return { ...viewport, width: viewport.height, height: viewport.width }; } ``` ### 6.15 PDF Variable Page Sizes **File:** `web/src/reader/pdf/pdf-page-sizes.ts` ```typescript // Handle PDFs with variable page sizes // Procedural implementation (no OOP) interface PageInfo { pageNumber: number; width: number; height: number; rotation: number; } interface PDFPageSizesState { pageSizes: Map; defaultSize: { width: number; height: number }; } function createPDFPageSizes(): PDFPageSizesState { return { pageSizes: new Map(), defaultSize: { width: 595, height: 842 } }; } async function loadPDFPageSizes( state: PDFPageSizesState, doc: any ): Promise { const pageSizes = new Map(); for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale: 1 }); const pageInfo: PageInfo = { pageNumber: pageNum, width: viewport.width, height: viewport.height, rotation: viewport.rotation }; pageSizes.set(pageNum, pageInfo); } return { ...state, pageSizes }; } function getPDFPageSize( state: PDFPageSizesState, pageNumber: number ): PageInfo | null { return state.pageSizes.get(pageNumber) || null; } function isPDFPageLandscape( state: PDFPageSizesState, pageNumber: number ): boolean { const size = getPDFPageSize(state, pageNumber); if (!size) return false; const effectiveWidth = size.rotation === 90 || size.rotation === 270 ? size.height : size.width; const effectiveHeight = size.rotation === 90 || size.rotation === 270 ? size.width : size.height; return effectiveWidth > effectiveHeight; } function getPDFCommonSize(state: PDFPageSizesState): { width: number; height: number } { if (state.pageSizes.size === 0) { return state.defaultSize; } const sizeGroups: Map = new Map(); state.pageSizes.forEach((size) => { const key = getPageSizeKey(size.width, size.height); const existing = sizeGroups.get(key); if (existing) { existing.count++; } else { sizeGroups.set(key, { width: size.width, height: size.height, count: 1 }); } }); let mostCommon = state.defaultSize; let maxCount = 0; sizeGroups.forEach((size) => { if (size.count > maxCount) { maxCount = size.count; mostCommon = { width: size.width, height: size.height }; } }); return mostCommon; } function getPageSizeKey(width: number, height: number): string { const w = Math.round(width / 10) * 10; const h = Math.round(height / 10) * 10; return `${w}x${h}`; } ``` --- ## 7. Panel Detection Implementation ### 7.1 Grid-Based Detection (Primary) **File:** `web/src/reader/comic/panel-detector.ts` ```typescript // Grid-based panel detection (fast, lightweight) interface GridConfig { rows: number; cols: number; } function detectPanelsGrid( imageData: ImageData, config: GridConfig = { rows: 3, cols: 3 } ): Panel[] { const panels: Panel[] = []; const cellWidth = imageData.width / config.cols; const cellHeight = imageData.height / config.rows; for (let y = 0; y < config.rows; y++) { for (let x = 0; x < config.cols; x++) { const cell = extractCell(imageData, x, y, cellWidth, cellHeight); if (!isEmpty(cell)) { panels.push({ id: `panel-${panels.length}`, x: (x / config.cols) * 100, y: (y / config.rows) * 100, width: (1 / config.cols) * 100, height: (1 / config.rows) * 100, reading_order: panels.length }); } } } return mergeAdjacentPanels(panels); } function isEmpty(cellData: ImageData): boolean { // Simple edge detection to find empty space // Count white/transparent pixels let emptyPixels = 0; const totalPixels = cellData.width * cellData.height; const threshold = 0.95; // 95% empty = empty cell for (let i = 0; i < cellData.data.length; i += 4) { const r = cellData.data[i]; const g = cellData.data[i + 1]; const b = cellData.data[i + 2]; const a = cellData.data[i + 3]; // Consider white or transparent as empty if (a < 10 || (r > 250 && g > 250 && b > 250)) { emptyPixels++; } } return (emptyPixels / totalPixels) > threshold; } function mergeAdjacentPanels(panels: Panel[]): Panel[] { // Merge panels that are next to each other // Simplified algorithm - can be enhanced const merged: Panel[] = []; const used = new Set(); for (let i = 0; i < panels.length; i++) { if (used.has(i)) continue; let current = { ...panels[i] }; used.add(i); // Look for adjacent panels for (let j = i + 1; j < panels.length; j++) { if (used.has(j)) continue; if (isAdjacent(current, panels[j])) { current = mergePanels(current, panels[j]); used.add(j); } } merged.push(current); } return merged; } ``` ### 7.2 ML-Based Detection (Enhancement) **File:** `web/src/reader/comic/panel-ml-detector.ts` ```typescript // ML-based panel detection (optional, lazy-loaded) // Uses TensorFlow.js for accurate panel detection let modelLoaded = false; let panelModel: any = null; async function loadMLModel(): Promise { if (modelLoaded) return; try { // Lazy-load TensorFlow.js await import('@tensorflow/tfjs'); // Load pre-trained model for panel detection // Model should be small (~2MB) and fast panelModel = await loadModel('/static/models/panel-detection/model.json'); modelLoaded = true; } catch (error) { console.error('Failed to load ML model:', error); // Fall back to grid-based detection } } async function detectPanelsML(imageData: ImageData): Promise { if (!modelLoaded) { await loadMLModel(); } if (!panelModel) { // Fall back to grid-based return detectPanelsGrid(imageData); } // Run ML model const predictions = await panelModel.detect(imageData); // Convert predictions to Panel format return predictions.map((pred: any, index: number) => ({ id: `ml-panel-${index}`, x: pred.bbox.x * 100, y: pred.bbox.y * 100, width: pred.bbox.width * 100, height: pred.bbox.height * 100, reading_order: index })); } ``` ### 7.3 Manual Override **File:** `web/src/reader/comic/panel-editor.ts` ```typescript // Manual panel editor for admins/power users function openPanelEditor(pageNumber: number): void { const modal = document.getElementById('panel-editor-modal'); modal?.classList.remove('hidden'); // Load page image const canvas = document.getElementById('panel-editor-canvas') as HTMLCanvasElement; const ctx = canvas?.getContext('2d'); // Load image and draw to canvas loadImageForPage(pageNumber).then((image) => { canvas!.width = image.width; canvas!.height = image.height; ctx?.drawImage(image, 0, 0); // Allow user to draw panels enablePanelDrawing(canvas!); }); } function enablePanelDrawing(canvas: HTMLCanvasElement): void { let isDrawing = false; let startX = 0; let startY = 0; canvas.addEventListener('mousedown', (e) => { isDrawing = true; startX = e.offsetX; startY = e.offsetY; }); canvas.addEventListener('mousemove', (e) => { if (!isDrawing) return; // Draw selection rectangle const ctx = canvas.getContext('2d'); ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY); }); canvas.addEventListener('mouseup', (e) => { if (!isDrawing) return; isDrawing = false; // Save panel const panel: Panel = { id: `manual-${Date.now()}`, x: (startX / canvas.width) * 100, y: (startY / canvas.height) * 100, width: ((e.offsetX - startX) / canvas.width) * 100, height: ((e.offsetY - startY) / canvas.height) * 100, reading_order: 0 // Will be set by server }; saveManualPanel(panel); }); } async function saveManualPanel(panel: Panel): Promise { const mediaItemId = document.body.dataset.mediaItemId; const pageNumber = getCurrentPageNumber(); await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, { detection_method: 'manual', panels: [panel] }); // Reload with new panels loadPage(pageNumber); } ``` ### 7.4 Page Order Presets **File:** `web/src/reader/comic/page-order.ts` ```typescript // Page order presets for manga/comics // Auto-detect Japanese vs Western reading order // Allow user override in case detection is wrong // Procedural implementation (no OOP) type PageOrderMode = 'auto' | 'japanese' | 'western'; interface PageOrderConfig { mode: PageOrderMode; detectedOrder: PageOrderMode; userOverride: boolean; } interface PageOrderState { config: PageOrderConfig; totalPages: number; } // Detect page order based on filename patterns function detectPageOrder(pageNames: string[]): PageOrderMode { if (pageNames.length < 2) return 'western'; const firstPage = pageNames[0].toLowerCase(); const lastPage = pageNames[pageNames.length - 1].toLowerCase(); const hasFrontCover = /cover|front|001/.test(firstPage); const hasBackCover = /back|end|ζœ€εŽηš„/.test(lastPage); if (hasFrontCover && !hasBackCover) { return 'western'; } if (hasBackCover && !hasFrontCover) { return 'japanese'; } const chapterMatches = pageNames.filter(n => /ch-\d+|chapter/i.test(n)); if (chapterMatches.length > 0) { const firstChapter = chapterMatches[0]; const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || '0'); return pageNum > 0 ? 'western' : 'japanese'; } return 'western'; } function createPageOrderState(totalPages: number, pageNames: string[]): PageOrderState { const detectedOrder = detectPageOrder(pageNames); return { config: { mode: 'auto', detectedOrder, userOverride: false }, totalPages }; } function setPageOrderMode(state: PageOrderState, mode: PageOrderMode): PageOrderState { return { ...state, config: { ...state.config, mode, userOverride: mode !== 'auto' } }; } function getPageOrder(state: PageOrderState): PageOrderMode { if (state.config.mode === 'auto') { return state.config.detectedOrder; } return state.config.mode; } function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { const order = getPageOrder(state); if (order === 'japanese') { return [...pageNumbers].reverse(); } return pageNumbers; } function getDisplayPageNumber( state: PageOrderState, actualPage: number ): number { const order = getPageOrder(state); if (order === 'japanese') { return state.totalPages - actualPage + 1; } return actualPage; } ``` ### 7.5 Extended Keyboard Shortcuts **File:** `web/src/reader/keyboard-shortcuts.ts` ```typescript // Extended keyboard shortcuts for all readers // Procedural implementation (no OOP) interface KeyboardShortcutHandler { onNextPage: () => void; onPreviousPage: () => void; onNextChapter: () => void; onPreviousChapter: () => void; onGoToPage: (page: number) => void; onToggleBookmark: () => void; onZoomIn: () => void; onZoomOut: () => void; onToggleFullscreen: () => void; onClose: () => void; } function setupKeyboardShortcuts( container: HTMLElement, handlers: KeyboardShortcutHandler, maxPage: number ): void { container.addEventListener('keydown', (e) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } switch (e.key) { case 'ArrowRight': case 'PageDown': case 'l': e.preventDefault(); handlers.onNextPage(); break; case 'ArrowLeft': case 'PageUp': case 'h': e.preventDefault(); handlers.onPreviousPage(); break; case 'ArrowUp': case 'k': e.preventDefault(); handlers.onPreviousPage(); break; case 'ArrowDown': case 'j': e.preventDefault(); handlers.onNextPage(); break; case ' ': e.preventDefault(); handlers.onNextPage(); break; case 'Home': e.preventDefault(); handlers.onGoToPage(1); break; case 'End': e.preventDefault(); handlers.onGoToPage(maxPage); break; case 'b': if (!e.ctrlKey && !e.metaKey) { e.preventDefault(); handlers.onToggleBookmark(); } break; case '+': case '=': e.preventDefault(); handlers.onZoomIn(); break; case '-': case '_': e.preventDefault(); handlers.onZoomOut(); break; case '0': e.preventDefault(); handlers.onZoomIn(); handlers.onZoomIn(); handlers.onZoomIn(); break; case 'f': if (!e.ctrlKey && !e.metaKey) { e.preventDefault(); handlers.onToggleFullscreen(); } break; case 'Escape': e.preventDefault(); handlers.onClose(); break; default: if (e.key >= '1' && e.key <= '9') { const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage); e.preventDefault(); handlers.onGoToPage(targetPage); } } }); } function showShortcutHelp(): void { const help = document.createElement('div'); help.className = 'keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50'; help.innerHTML = `

Keyboard Shortcuts

β†’ / Space Next page
← Previous page
Home First page
End Last page
+ / - Zoom
B Toggle bookmark
F Fullscreen
1-9 Jump to %
`; document.body.appendChild(help); help.addEventListener('click', (e) => { if (e.target === help) help.remove(); }); } ``` ### 7.6 Page Slider/Scrubber **File:** `web/src/reader/comic/page-scrubber.ts` ```typescript // Page slider/scrubber for quick navigation // Procedural implementation (no OOP) interface PageScrubberState { currentPage: number; totalPages: number; container: HTMLElement; } function createPageScrubber( container: HTMLElement, currentPage: number, totalPages: number ): PageScrubberState { const state: PageScrubberState = { currentPage, totalPages, container }; renderPageScrubber(state); return state; } function renderPageScrubber(state: PageScrubberState): void { const existing = state.container.querySelector('.page-scrubber'); existing?.remove(); const scrubber = document.createElement('div'); scrubber.className = 'page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40'; scrubber.innerHTML = ` ${state.currentPage} ${state.totalPages} `; const slider = scrubber.querySelector('.page-slider') as HTMLInputElement; slider.addEventListener('input', (e) => { const targetPage = parseInt((e.target as HTMLInputElement).value); updatePageScrubber(state, targetPage); }); slider.addEventListener('change', () => { const targetPage = parseInt(slider.value); navigateToPage(targetPage); }); state.container.appendChild(scrubber); } function updatePageScrubber(state: PageScrubberState, currentPage: number): PageScrubberState { const newState = { ...state, currentPage }; const label = state.container.querySelector('.page-label'); if (label) { label.textContent = String(currentPage); } return newState; } function showPageScrubber(state: PageScrubberState): void { const scrubber = state.container.querySelector('.page-scrubber'); scrubber?.classList.remove('hidden'); } function hidePageScrubber(state: PageScrubberState): void { const scrubber = state.container.querySelector('.page-scrubber'); scrubber?.classList.add('hidden'); } function navigateToPage(page: number): void { window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page } })); } ``` ### 7.7 Gesture Controls **File:** `web/src/reader/gestures.ts` ```typescript // Touch gesture controls for mobile/tablet // Procedural implementation (no OOP) interface GestureHandlers { onSwipeLeft: () => void; onSwipeRight: () => void; onSwipeUp: () => void; onSwipeDown: () => void; onPinch: (scale: number) => void; onTap: () => void; onDoubleTap: () => void; } interface GestureState { touchStartX: number; touchStartY: number; touchStartTime: number; lastTapTime: number; initialPinchDistance: number; scale: number; } function setupGestureControls( container: HTMLElement, handlers: GestureHandlers ): void { let state: GestureState = { touchStartX: 0, touchStartY: 0, touchStartTime: 0, lastTapTime: 0, initialPinchDistance: 0, scale: 1 }; container.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { state.touchStartX = e.touches[0].clientX; state.touchStartY = e.touches[0].clientY; state.touchStartTime = Date.now(); } else if (e.touches.length === 2) { state.initialPinchDistance = getPinchDistance(e.touches); } }, { passive: true }); container.addEventListener('touchend', (e) => { const deltaX = e.changedTouches[0].clientX - state.touchStartX; const deltaY = e.changedTouches[0].clientY - state.touchStartY; const deltaTime = Date.now() - state.touchStartTime; if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) { const now = Date.now(); if (now - state.lastTapTime < 300) { handlers.onDoubleTap(); state.lastTapTime = 0; } else { state.lastTapTime = now; setTimeout(() => { if (state.lastTapTime !== 0) { handlers.onTap(); } }, 300); } return; } const minSwipeDistance = 50; const maxSwipeTime = 500; if (deltaTime > maxSwipeTime) return; if (Math.abs(deltaX) > Math.abs(deltaY)) { if (deltaX > minSwipeDistance) { handlers.onSwipeRight(); } else if (deltaX < -minSwipeDistance) { handlers.onSwipeLeft(); } } else { if (deltaY > minSwipeDistance) { handlers.onSwipeDown(); } else if (deltaY < -minSwipeDistance) { handlers.onSwipeUp(); } } }, { passive: true }); container.addEventListener('touchmove', (e) => { if (e.touches.length === 2) { const currentDistance = getPinchDistance(e.touches); if (state.initialPinchDistance > 0) { const scale = currentDistance / state.initialPinchDistance; state.scale = scale; handlers.onPinch(scale); } } }, { passive: true }); } function getPinchDistance(touches: TouchList): number { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.sqrt(dx * dx + dy * dy); } ``` ### 7.8 Panel Gap Controls **File:** `web/src/reader/comic/panel-gap.ts` ```typescript // Adjustable panel gap controls // Procedural implementation (no OOP) interface PanelGapState { gapSize: number; showBorders: boolean; } function createPanelGapState(initialGap: number = 4): PanelGapState { return { gapSize: initialGap, showBorders: false }; } function setPanelGap(state: PanelGapState, gap: number): PanelGapState { const clampedGap = Math.max(0, Math.min(20, gap)); document.documentElement.style.setProperty('--panel-gap', `${clampedGap}px`); return { ...state, gapSize: clampedGap }; } function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { return setPanelGap(state, state.gapSize + amount); } function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { return setPanelGap(state, state.gapSize - amount); } function togglePanelBorders(state: PanelGapState): PanelGapState { const newShowBorders = !state.showBorders; document.documentElement.style.setProperty( '--panel-border-width', newShowBorders ? '1px' : '0px' ); return { ...state, showBorders: newShowBorders }; } function renderPanelGapControls(container: HTMLElement, state: PanelGapState): void { const existing = container.querySelector('.panel-gap-controls'); existing?.remove(); const controls = document.createElement('div'); controls.className = 'panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40'; controls.innerHTML = ` ${state.gapSize}px `; controls.querySelector('.panel-gap-increase')?.addEventListener('click', () => { const newState = increasePanelGap(state); updatePanelGapUI(controls, newState); }); controls.querySelector('.panel-gap-decrease')?.addEventListener('click', () => { const newState = decreasePanelGap(state); updatePanelGapUI(controls, newState); }); controls.querySelector('.panel-gap-borders')?.addEventListener('click', () => { const newState = togglePanelBorders(state); updatePanelGapUI(controls, newState); }); container.appendChild(controls); } function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { const gapLabel = container.querySelector('span'); if (gapLabel) { gapLabel.textContent = `${state.gapSize}px`; } const bordersBtn = container.querySelector('.panel-gap-borders'); if (bordersBtn) { bordersBtn.textContent = state.showBorders ? 'β–¦' : 'β–’'; } } const panelGapCSS = ` :root { --panel-gap: 4px; --panel-border-width: 0px; } .panel-zoom-container { gap: var(--panel-gap); } .panel-zoom-container.with-borders { background: rgba(255, 255, 255, 0.1); padding: var(--panel-gap); } .panel-borders { border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3); } `; ``` ### 7.9 Background Color Options **File:** `web/src/reader/comic/background-color.ts` ```typescript // Background color options for manga/comics // Procedural implementation (no OOP) type BackgroundColor = 'black' | 'white' | 'gray' | 'sepia' | 'custom'; interface BackgroundColorState { current: BackgroundColor; customColor: string; } const backgroundColors: Record = { black: '#000000', white: '#ffffff', gray: '#333333', sepia: '#f4ecd8', custom: '' }; function createBackgroundColorState( initial: BackgroundColor = 'black' ): BackgroundColorState { return { current: initial, customColor: '#000000' }; } function setBackgroundColor( state: BackgroundColorState, color: BackgroundColor, customColor?: string ): BackgroundColorState { const newState: BackgroundColorState = { current: color, customColor: customColor || state.customColor }; const bgColor = color === 'custom' ? newState.customColor : backgroundColors[color]; document.documentElement.style.setProperty('--reader-bg-color', bgColor); const viewer = document.querySelector('.reader-content'); if (viewer) { viewer.style.backgroundColor = bgColor; } localStorage.setItem('reader-background-color', color); return newState; } function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState { const order: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; const currentIndex = order.indexOf(state.current); const nextIndex = (currentIndex + 1) % order.length; return setBackgroundColor(state, order[nextIndex]); } function renderBackgroundColorPicker( container: HTMLElement, state: BackgroundColorState ): void { const existing = container.querySelector('.background-color-picker'); existing?.remove(); const picker = document.createElement('div'); picker.className = 'background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40'; const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; colors.forEach(color => { const btn = document.createElement('button'); btn.className = `w-8 h-8 rounded-full border-2 ${ state.current === color ? 'border-blue-500' : 'border-transparent' }`; btn.style.backgroundColor = backgroundColors[color]; btn.title = color.charAt(0).toUpperCase() + color.slice(1); btn.addEventListener('click', () => { const newState = setBackgroundColor(state, color); updateBackgroundColorUI(picker, newState); }); picker.appendChild(btn); }); container.appendChild(picker); } function updateBackgroundColorUI(container: HTMLElement, state: BackgroundColorState): void { const buttons = container.querySelectorAll('button'); const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; buttons.forEach((btn, index) => { btn.classList.toggle('border-blue-500', colors[index] === state.current); }); } ``` ### 7.10 Chapter Markers **File:** `web/src/reader/comic/chapter-markers.ts` ```typescript // Chapter markers for manga/comics // Visual indicators for chapter boundaries // Procedural implementation (no OOP) interface ChapterInfo { chapterNumber: number; pageStart: number; pageEnd: number; title?: string; } interface ChapterMarkerState { chapters: ChapterInfo[]; currentChapter: number; showMarkers: boolean; } function createChapterMarkerState( chapters: ChapterInfo[], currentPage: number ): ChapterMarkerState { const currentChapter = chapters.find( c => currentPage >= c.pageStart && currentPage <= c.pageEnd )?.chapterNumber || 1; return { chapters, currentChapter, showMarkers: true }; } function renderChapterMarkers( container: HTMLElement, state: ChapterMarkerState ): void { if (!state.showMarkers) return; const markersContainer = document.createElement('div'); markersContainer.className = 'chapter-markers absolute left-0 right-0 pointer-events-none z-10'; state.chapters.forEach(chapter => { const marker = document.createElement('div'); marker.className = 'chapter-marker flex items-center gap-2 text-sm text-gray-400'; const isCurrentChapter = chapter.chapterNumber === state.currentChapter; marker.style.position = 'absolute'; marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; marker.style.left = '10px'; marker.innerHTML = ` ${chapter.title || `Chapter ${chapter.chapterNumber}`} p.${chapter.pageStart} ${isCurrentChapter ? '←' : ''} `; markersContainer.appendChild(marker); }); const existing = container.querySelector('.chapter-markers'); existing?.remove(); container.appendChild(markersContainer); } function updateCurrentChapter( state: ChapterMarkerState, currentPage: number ): ChapterMarkerState { const currentChapter = state.chapters.find( c => currentPage >= c.pageStart && currentPage <= c.pageEnd )?.chapterNumber || state.currentChapter; if (currentChapter !== state.currentChapter) { const newState = { ...state, currentChapter }; const markers = document.querySelector('.chapter-markers'); if (markers) { renderChapterMarkers(markers.parentElement!, newState); } return newState; } return state; } function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { const newState = { ...state, showMarkers: !state.showMarkers }; const markers = document.querySelector('.chapter-markers'); if (markers) { markers.classList.toggle('hidden', !newState.showMarkers); } return newState; } function scrollToChapter( state: ChapterMarkerState, chapterNumber: number ): void { const chapter = state.chapters.find(c => c.chapterNumber === chapterNumber); if (chapter) { window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page: chapter.pageStart } })); } } const chapterMarkerCSS = ` .chapter-marker { padding: 4px 8px; margin-left: -18px; opacity: 0.7; transition: opacity 0.2s; } .chapter-marker:hover { opacity: 1; } .chapter-marker .current-indicator { color: #3b82f6; animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .chapter-marker-line { position: absolute; left: 0; right: 0; height: 1px; background: linear-gradient(to right, rgba(255,255,255,0.1), transparent); } `; ``` --- ## 8. Lazy Loading & Caching ### 8.1 Page Cache (5-Page Ahead) **File:** `web/src/reader/comic/page-cache.ts` ```typescript // Lazy-loading page cache with 5-page ahead prefetch // Lazy-loading page cache with 5-page ahead prefetch // Procedural implementation (no OOP) interface PageCacheState { cache: Map; loading: Set; maxAhead: number; mediaItemId: string; } function createPageCache(mediaItemId: string): PageCacheState { return { cache: new Map(), loading: new Set(), maxAhead: 5, mediaItemId }; } async function getCachedPage( state: PageCacheState, pageNumber: number ): Promise { if (state.cache.has(pageNumber)) { return { ...state, page: state.cache.get(pageNumber)! }; } if (state.loading.has(pageNumber)) { return new Promise((resolve) => { const checkInterval = setInterval(() => { if (state.cache.has(pageNumber)) { clearInterval(checkInterval); resolve({ ...state, page: state.cache.get(pageNumber)! }); } }, 100); }) as Promise; } const newLoading = new Set(state.loading); newLoading.add(pageNumber); const img = await loadComicPage(state, pageNumber); const newCache = new Map(state.cache); newCache.set(pageNumber, img); newLoading.delete(pageNumber); const newState = { ...state, cache: newCache, loading: newLoading }; prefetchPages(newState, pageNumber + 1); cleanupPageCache(newState, pageNumber); return { ...newState, page: img }; } async function loadComicPage( state: PageCacheState, pageNumber: number ): Promise { const token = localStorage.getItem('token'); const response = await fetch( `/api/readers/${state.mediaItemId}/pages/${pageNumber}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!response.ok) { throw new Error(`Failed to load page ${pageNumber}`); } const blob = await response.blob(); const img = new Image(); img.src = URL.createObjectURL(blob); await new Promise((resolve) => { img.onload = resolve; }); return img; } function prefetchPages(state: PageCacheState, startPage: number): void { for (let i = startPage; i < startPage + state.maxAhead; i++) { if (!state.cache.has(i) && !state.loading.has(i)) { loadComicPage(state, i).then((img) => { state.cache.set(i, img); }); } } } function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState { const keepPages = 10; const newCache = new Map(state.cache); for (const [page] of state.cache) { if (page < currentPage - keepPages) { newCache.delete(page); } } return { ...state, cache: newCache }; } ``` --- ## 9. Offline Support (PWA) ### 9.1 Service Worker **File:** `web/static/service-worker.js` (new file) ```javascript // Service worker for offline reading const CACHE_NAME = 'bookhoard-reader-v1'; const OFFLINE_CACHE = 'bookhoard-offline'; // Cache dictionary data for offline use self.addEventListener('install', (event) => { event.waitUntil( caches.open(OFFLINE_CACHE).then((cache) => { return cache.addAll([ '/static/dictionary/en-US.json', '/static/dictionary/en-GB.json' ]); }) ); }); // Cache reader pages self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Cache reader pages if (url.pathname.startsWith('/api/readers/') && url.pathname.includes('/pages/')) { event.respondWith( caches.open(CACHE_NAME).then((cache) => { return cache.match(event.request).then((response) => { if (response) { return response; } // Fetch and cache return fetch(event.request).then((networkResponse) => { cache.put(event.request, networkResponse.clone()); return networkResponse; }); }); }) ); } // Cache dictionary lookups if (url.pathname.startsWith('/api/readers/dictionary/')) { event.respondWith( caches.open(OFFLINE_CACHE).then((cache) => { return cache.match(event.request).then((response) => { if (response) { return response; } return fetch(event.request).then((networkResponse) => { // Cache dictionary responses cache.put(event.request, networkResponse.clone()); return networkResponse; }); }); }) ); } }); // Cleanup old caches self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) { return caches.delete(cacheName); } }) ); }) ); }); ``` ### 9.2 PWA Manifest **File:** `web/static/manifest.json` (new file) ```json { "name": "Bookhoard Reader", "short_name": "Reader", "description": "Offline-capable ebook and comic reader", "start_url": "/dashboard", "display": "fullscreen", "background_color": "#1a1b26", "theme_color": "#1a1b26", "icons": [ { "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ], "offline_enabled": true } ``` ### 9.3 Register Service Worker **File:** `web/src/reader/offline-manager.ts` (new file) ```typescript // Offline manager for PWA functionality export function registerServiceWorker(): void { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/static/service-worker.js') .then((registration) => { console.log('Service worker registered:', registration); }) .catch((error) => { console.error('Service worker registration failed:', error); }); } } export function checkOnlineStatus(): boolean { if (typeof navigator !== 'undefined' && navigator.onLine) { return true; } return false; } // Listen for online/offline events window.addEventListener('online', () => { showToast('Back online', 'success'); // Sync any pending changes syncPendingChanges(); }); window.addEventListener('offline', () => { showToast('You are offline. Some features may be limited.', 'warning'); }); ``` --- ## 10. Dictionary Implementation ### 10.1 Dictionary Data **File:** `web/static/dictionary/en-US.json` (new file) Compressed dictionary data with common words. Format: ```json { "word": { "definition": "A single distinct meaningful element of speech or writing", "part_of_speech": "noun", "example": "The words 'the', 'and', and 'word' are examples of words.", "etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort." } } ``` Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage. ### 10.2 Dictionary Popup **File:** `web/src/reader/ebook/dictionary-popup.ts` ```typescript // Dictionary lookup popup for ebooks import { lookupWord } from "./api"; function showDictionaryPopup(word: string, position: { x: number; y: number }): void { // Remove existing popup const existing = document.getElementById('dictionary-popup'); existing?.remove(); // Create popup const popup = document.createElement('div'); popup.id = 'dictionary-popup'; popup.className = 'absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50'; popup.style.left = `${position.x}px`; popup.style.top = `${position.y}px`; popup.innerHTML = '

Loading...

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

${entry.word}

${entry.part_of_speech || ''}

${entry.definition}

${entry.example ? `

"${entry.example}"

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

Definition not found for "${word}"

`; }); // Close on click outside setTimeout(() => { document.addEventListener('click', function closePopup(e: MouseEvent) { if (!popup.contains(e.target as Node)) { popup.remove(); document.removeEventListener('click', closePopup); } }); }, 100); } // Text selection handler for ebooks function handleTextSelection(): void { document.addEventListener('mouseup', () => { const selection = window.getSelection(); const selectedText = selection?.toString().trim(); if (selectedText && selectedText.split(' ').length === 1) { // Single word selected - show dictionary const range = selection?.getRangeAt(0); const rect = range?.getBoundingClientRect(); if (rect) { showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom }); } } }); } ``` --- ## 11. Reading Statistics Integration ### 11.1 Track Reading Speed **File:** `web/src/reader/reading-speed-tracker.ts` ```typescript // Track reading speed and update database // Reading speed tracker // Procedural implementation (no OOP) interface ReadingSpeedTrackerState { startTime: number | null; pagesRead: number; wordsRead: number; lastSync: number; mediaItemId: string; } function createReadingSpeedTracker(mediaItemId: string): ReadingSpeedTrackerState { return { startTime: null, pagesRead: 0, wordsRead: 0, lastSync: Date.now(), mediaItemId }; } function startReadingSession(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { return { ...state, startTime: Date.now(), pagesRead: 0, wordsRead: 0 }; } function recordPageTurn(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState { if (!state.startTime) return state; const newPagesRead = state.pagesRead + 1; const now = Date.now(); if (newPagesRead % 5 === 0 || (now - state.lastSync) > 5 * 60 * 1000) { syncReadingSpeed({ ...state, pagesRead: newPagesRead }); return { ...state, pagesRead: newPagesRead, lastSync: now }; } return { ...state, pagesRead: newPagesRead }; } function recordWordsRead( state: ReadingSpeedTrackerState, wordCount: number ): ReadingSpeedTrackerState { return { ...state, wordsRead: state.wordsRead + wordCount }; } async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise { if (!state.startTime) return; const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60); const pagesPerMinute = state.pagesRead / minutesElapsed; const wordsPerMinute = state.wordsRead / minutesElapsed; await apiPut(`/readers/${state.mediaItemId}/reading-speed`, { pages_per_minute: pagesPerMinute, words_per_minute: wordsPerMinute, pages_read: state.pagesRead, total_reading_minutes: minutesElapsed }); } ``` --- ## 12. UI/UX Implementation ### 12.1 Reader Template (SSR) **File:** `templates/reader.templ` (new file) ```go package templates templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) { { metadata.title } - Bookhoard Reader @ReaderChrome(user, metadata, progress)
@DictionaryPopup() } templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
← Back

{ metadata.title }

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

βš™οΈ Settings

Display

Typography

Navigation

} templ ReaderTOCPanel(metadata ReaderMetadata) {

πŸ“– Table of Contents

} templ ReaderNavigatorPanel() { } templ ReaderBookmarksPanel(bookmarks []Bookmark) {

πŸ”– Bookmarks

if len(bookmarks) > 0 {
for _, bookmark := range bookmarks { { bookmark.Title } { bookmark.Position } }
} else {

No bookmarks yet

}
} templ DictionaryPopup() { } ``` --- ## 13. Integration Tests ### 13.1 Test Setup **File:** `cmd/server/tests/reader_test.go` (new file) Follow existing test patterns from `media_test.go` and `auth_test.go`: ```go package tests import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReaderEndpoints(t *testing.T) { setup := setupTestServer(t) defer setup.Teardown(t) // Create test user and media item ctx := setup.Ctx() queries := setup.Queries() user := createTestUser(t, ctx, queries) admin := createTestAdmin(t, ctx, queries) library := createTestLibrary(t, ctx, queries, user.ID) mediaItem := createTestMediaItem(t, ctx, queries, library.ID, user.ID) accessToken := loginTestUser(t, setup, user.Email, "password123") adminToken := loginTestUser(t, setup, admin.Email, "admin123") t.Run("Get Reader Page - User", func(t *testing.T) { // Test SSR reader page // Test that user can access their own media items req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "text/html") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) assert.Contains(t, resp.Body.String(), mediaItem.Title) }) t.Run("Get Reader Page - No User", func(t *testing.T) { // Test 401 without authentication req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) req.Header.Set("Accept", "text/html") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) // Should redirect to login for HTML requests assert.Equal(t, http.StatusFound, resp.Code) }) t.Run("Get Reader Page - API Request", func(t *testing.T) { // Test JSON API request returns 401 without auth req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil) req.Header.Set("Accept", "application/json") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusUnauthorized, resp.Code) }) t.Run("Get Page - Lazy Loading", func(t *testing.T) { // Test page lazy loading endpoint req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/pages/1", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) // Should return 200 with page content assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.Contains(t, result, "content") assert.Contains(t, result, "page_number") }) t.Run("Get Chapters", func(t *testing.T) { // Test chapter metadata endpoint req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/chapters", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.Contains(t, result, "chapters") }) t.Run("Get Panels - Grid Detection", func(t *testing.T) { // Test panel detection endpoint for comics // Create a comic media item comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID) req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/panels/1?method=grid", comicItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.Contains(t, result, "panels") assert.Contains(t, result, "detection_method") }) t.Run("Update Panels - Manual Override", func(t *testing.T) { // Test manual panel override (all authenticated users) comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID) panels := []map[string]interface{}{ { "id": "panel-1", "x": 100, "y": 50, "width": 400, "height": 300, "reading_order": 1, }, } requestBody := map[string]interface{}{ "panels": panels, "detection_method": "manual", } bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) req := httptest.NewRequest("PUT", fmt.Sprintf("/api/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err = json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.True(t, result["success"].(bool)) }) t.Run("Reading Speed - Get", func(t *testing.T) { // Test reading speed retrieval req := httptest.NewRequest("GET", fmt.Sprintf("/api/readers/%s/reading-speed", mediaItem.ID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) // May have zero values if no reading has occurred assert.Contains(t, result, "pages_read") }) t.Run("Reading Speed - Update", func(t *testing.T) { // Test reading speed update requestBody := map[string]interface{}{ "pages_read": 10, "time_spent_minutes": 15.5, } bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) req := httptest.NewRequest("POST", fmt.Sprintf("/api/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err = json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.True(t, result["success"].(bool)) }) t.Run("Dictionary Lookup", func(t *testing.T) { // Test dictionary endpoint req := httptest.NewRequest("GET", "/api/readers/dictionary/example", nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) // Should return 200 or 404 depending on whether word is in cache assert.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound) if resp.Code == http.StatusOK { var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.Contains(t, result, "word") assert.Contains(t, result, "definition") } }) t.Run("Settings Management - Get", func(t *testing.T) { // Test settings retrieval req := httptest.NewRequest("GET", "/api/readers/settings", nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) // Should have default settings if never set assert.Contains(t, result, "reading_theme") assert.Contains(t, result, "font_size") }) t.Run("Settings Management - Update", func(t *testing.T) { // Test settings update requestBody := map[string]interface{}{ "reading_theme": "dark", "reading_font": "literata", "font_size": 18, } bodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) req := httptest.NewRequest("PUT", "/api/readers/settings", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) // Verify settings were updated var result map[string]interface{} err = json.Unmarshal(resp.Body.Bytes(), &result) require.NoError(t, err) assert.Equal(t, "dark", result["reading_theme"]) assert.Equal(t, "literata", result["reading_font"]) assert.Equal(t, float64(18), result["font_size"]) }) t.Run("Offline Support - Service Worker", func(t *testing.T) { // Test service worker registration req := httptest.NewRequest("GET", "/static/sw.js", nil) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) assert.Contains(t, resp.Body.String(), "serviceWorker") }) t.Run("Offline Support - Manifest", func(t *testing.T) { // Test PWA manifest req := httptest.NewRequest("GET", "/static/manifest.json", nil) resp := httptest.NewRecorder() setup.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) var manifest map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &manifest) require.NoError(t, err) assert.Contains(t, manifest, "name") assert.Contains(t, manifest, "start_url") assert.Contains(t, manifest, "display") }) } // Helper function to create test comic media item func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *database.Queries, libraryID uuid.UUID, userID uuid.UUID) database.MediaItems { mangaType := pgtype.Text{String: "yes", Valid: true} readingDirection := pgtype.Text{String: "rtl", Valid: true} item, err := queries.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Title: "Test Comic", FilePath: "/test/comic.cbz", FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/vnd.comicbook+zip", Valid: true}, AddedByAdminID: pgtype.UUID{Bytes: userID, Valid: true}, MangaType: mangaType, ReadingDirection: readingDirection, }) require.NoError(t, err) return item } ``` --- ## 14. Phased Implementation ### Phase 1: Infrastructure & Basic Reader (Week 1-2) - [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings) - [ ] Create reader service layer (`internal/services/reader_service.go`) - [ ] Create reader handlers (`internal/handlers/reader.go`) - [ ] Register reader routes (`internal/router/reader.go`) - [ ] Create reader template (`templates/reader.templ`) - [ ] Implement reader shell infrastructure - [ ] Implement settings manager (DB + localStorage) - [ ] Implement progress indicator (KOReader-style) - [ ] Create basic ebook reader (HTML rendering) - [ ] Create basic comic reader (image display) - [ ] Integration tests for infrastructure ### Phase 2: Comic/Manga Features (Week 3-4) - [ ] Implement grid-based panel detection - [ ] Implement panel zoom with animations - [ ] Implement page cache (5-page ahead) - [ ] Implement manga RTL navigator - [ ] Implement manga vertical scroll mode - [ ] Implement chapter detection for all media types - [ ] Integration tests for comic/manga features ### Phase 3: Advanced Features (Week 5-6) - [ ] Implement ML-based panel detection (optional enhancement) - [ ] Implement manual panel editor - [ ] Implement dictionary popup for ebooks - [ ] Implement offline dictionary cache - [ ] Implement reading speed tracker - [ ] Implement annotation manager (highlights, notes, bookmarks) - [ ] Integration tests for advanced features ### Phase 4: Offline Support (Week 7) - [ ] Create service worker - [ ] Implement page caching for offline reading - [ ] Implement dictionary offline caching - [ ] Create PWA manifest - [ ] Implement online/offline detection - [ ] Integration tests for offline support ### Phase 5: Polish & Testing (Week 8) - [ ] Performance optimization - [ ] Cross-browser testing - [ ] Mobile responsiveness testing - [ ] Accessibility testing - [ ] Security audit - [ ] Documentation (user guides, API docs) - [ ] End-to-end testing --- ## 15. Code Reuse Strategy ### 15.1 Reuse Existing Systems **WebSocket Sync (`internal/sync/websocket.go`)** - Reuse for real-time progress updates - Reuse for annotation sync - Reuse for bookmark sync **Progress Tracking (`internal/sync/progress.go`)** - Reuse EPUB CFI navigation logic - Reuse percentage calculation - Reuse chapter-relative page calculation **Format Handling (`internal/sync/format.go`)** - Reuse format detection logic - Reuse normalization functions **Annotation Tables (notes, highlights)** - Reuse existing database schema - Reuse existing API endpoints - Build UI on top of existing data **Theme System (11 dark themes)** - Reuse existing theme CSS variables - Apply theme to reader UI - Ensure consistency across app **Auth & User Management** - Reuse JWT middleware - Reuse user preferences - Reuse role-based access control ### 15.2 Surgical Code Edits **Avoid:** - ❌ Duplicating existing logic - ❌ Rewriting working code - ❌ Creating parallel systems **Do:** - βœ… Extend existing types where appropriate - βœ… Add new methods to existing services - βœ… Follow existing patterns and conventions - βœ… Use existing test helpers **Example - Extending sync/format.go:** ```go // EXISTING CODE in sync/format.go func CalculateProgress(currentPage, totalPages int) float64 { if totalPages == 0 { return 0 } return float64(currentPage) / float64(totalPages) * 100 } // NEW CODE - Add chapter-relative progress func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) (int, int) { chapterPage := currentPage - chapterStartPage + 1 return chapterPage, chapterPages } ``` --- ## 16. Bruno API Tests **File:** `bruno/reader/get-reader-page.bru` ```yaml meta: name: Get Reader Page type: http seq: 1 http: method: GET url: '{{base_url}}/readers/{{media_item_id}}' auth: inherit body: type: none docs: |- ## Get Reader Page Returns the reader page for a specific media item. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID **Response:** HTML reader page **Error Responses:** - 401: Invalid authentication - 404: Media item not found - 403: User does not have access to this library ``` **File:** `bruno/reader/get-page.bru` ```yaml meta: name: Get Book Page type: http seq: 2 http: method: GET url: '{{base_url}}/api/readers/{{media_item_id}}/pages/{{page_number}}' auth: inherit body: type: none docs: |- ## Get Book Page Retrieves a specific page of the book for rendering. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID - `page_number` (number): Page number to retrieve **Query Parameters:** - `format` (string, optional): Response format ('html', 'text', 'json') **Response:** - `content` (string): Page content (HTML or text) - `page_number` (number): Current page number - `total_pages` (number): Total pages in book - `chapter_title` (string, optional): Current chapter title **Error Responses:** - 401: Invalid authentication - 404: Page not found ``` **File:** `bruno/reader/get-chapters.bru` ```yaml meta: name: Get Chapters type: http seq: 3 http: method: GET url: '{{base_url}}/api/readers/{{media_item_id}}/chapters' auth: inherit body: type: none docs: |- ## Get Chapters Retrieves the table of contents/chapter list for a book. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID **Response:** ```json { "chapters": [ { "id": "chapter-1", "title": "Chapter 1", "start_page": 1, "page_count": 25, "level": 1 } ] } ``` **Error Responses:** - 401: Invalid authentication - 404: Media item not found ``` **File:** `bruno/reader/get-panels.bru` ```yaml meta: name: Get Comic Panels type: http seq: 4 http: method: GET url: '{{base_url}}/api/readers/{{media_item_id}}/panels/{{page_number}}' auth: inherit body: type: none docs: |- ## Get Comic Panels Retrieves panel detection data for a comic/manga page. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID - `page_number` (number): Page number **Query Parameters:** - `method` (string, optional): Detection method ('grid', 'ml', 'manual') **Response:** ```json { "page_number": 1, "detection_method": "ml", "panels": [ { "id": "panel-1", "x": 100, "y": 50, "width": 400, "height": 300, "reading_order": 1 } ] } ``` **Error Responses:** - 401: Invalid authentication - 404: Page not found ``` **File:** `bruno/reader/update-panels.bru` ```yaml meta: name: Update Comic Panels type: http seq: 5 http: method: PUT url: '{{base_url}}/api/readers/{{media_item_id}}/panels/{{page_number}}' auth: inherit body: type: json json: { "panels": [ { "id": "panel-1", "x": 100, "y": 50, "width": 400, "height": 300, "reading_order": 1 } ], "detection_method": "manual" } docs: |- ## Update Comic Panels Updates panel detection data (manual override). **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID - `page_number` (number): Page number **Request Body:** ```json { "panels": [...], "detection_method": "manual" } ``` **Response:** ```json { "success": true, "message": "Panels updated successfully" } ``` **Error Responses:** - 401: Invalid authentication - 400: Invalid panel data ``` **File:** `bruno/reader/get-reading-speed.bru` ```yaml meta: name: Get Reading Speed type: http seq: 6 http: method: GET url: '{{base_url}}/api/readers/{{media_item_id}}/reading-speed' auth: inherit body: type: none docs: |- ## Get Reading Speed Retrieves reading speed statistics for a book. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID **Response:** ```json { "words_per_minute": 250, "pages_per_minute": 1.5, "pages_read": 50, "total_reading_minutes": 33.3, "last_read_at": "2024-01-15T10:30:00Z" } ``` **Error Responses:** - 401: Invalid authentication - 404: Media item not found ``` **File:** `bruno/reader/update-reading-speed.bru` ```yaml meta: name: Update Reading Speed type: http seq: 7 http: method: POST url: '{{base_url}}/api/readers/{{media_item_id}}/reading-speed' auth: inherit body: type: json json: { "pages_read": 10, "time_spent_minutes": 15.5 } docs: |- ## Update Reading Speed Updates reading speed statistics. **Authentication:** Required (Bearer token) **Path Parameters:** - `media_item_id` (string): Media Item UUID **Request Body:** ```json { "pages_read": 10, "time_spent_minutes": 15.5 } ``` **Response:** ```json { "success": true, "words_per_minute": 250, "pages_per_minute": 1.5 } ``` **Error Responses:** - 401: Invalid authentication - 400: Invalid data ``` **File:** `bruno/reader/lookup-word.bru` ```yaml meta: name: Dictionary Lookup type: http seq: 8 http: method: GET url: '{{base_url}}/api/readers/dictionary/{{word}}' auth: inherit body: type: none docs: |- ## Dictionary Lookup Looks up a word in the dictionary (offline-capable). **Authentication:** Required (Bearer token) **Path Parameters:** - `word` (string): Word to look up **Query Parameters:** - `lang` (string, optional): Language code (default: 'en') **Response:** ```json { "word": "example", "definition": "A representative form or pattern.", "part_of_speech": "noun", "example": "This is an example sentence.", "etymology": "From Latin exemplum" } ``` **Error Responses:** - 401: Invalid authentication - 404: Word not found ``` **File:** `bruno/reader/get-settings.bru` ```yaml meta: name: Get Reader Settings type: http seq: 9 http: method: GET url: '{{base_url}}/api/readers/settings' auth: inherit body: type: none docs: |- ## Get Reader Settings Retrieves user's reader settings. **Authentication:** Required (Bearer token) **Response:** ```json { "chrome_behavior": "auto-hide", "progress_mode": "pages", "chrome_theme": "tokyo-night", "reading_theme": "dark", "reading_font": "literata", "font_size": 16, "line_height": 1.6, "margin_width": 20 } ``` **Error Responses:** - 401: Invalid authentication ``` **File:** `bruno/reader/update-settings.bru` ```yaml meta: name: Update Reader Settings type: http seq: 10 http: method: PUT url: '{{base_url}}/api/readers/settings' auth: inherit body: type: json json: { "reading_theme": "dark", "reading_font": "literata", "font_size": 18 } docs: |- ## Update Reader Settings Updates user's reader settings (partial update supported). **Authentication:** Required (Bearer token) **Request Body:** Partial settings object (only include fields to update) ```json { "reading_theme": "dark", "reading_font": "literata", "font_size": 18 } ``` **Response:** Updated settings object (same format as GET) **Error Responses:** - 401: Invalid authentication - 400: Invalid setting value ``` --- ## 17. Documentation ### 17.1 User Documentation **File:** `docs/user/reader.md` (new file) Comprehensive user guide covering: - How to open the reader - Navigation controls - Progress indicator modes - Settings options - Panel zoom for comics/manga - Dictionary lookup - Bookmarks, highlights, notes - Offline reading - Keyboard shortcuts ### 17.2 Developer Documentation **File:** `docs/contributing/reader-architecture.md` (new file) Technical documentation covering: - Reader architecture overview - Component structure - Data flow diagrams - Panel detection algorithms - Caching strategy - Offline support implementation - Testing strategy --- ## 18. Success Criteria ### 18.1 Functional Requirements - βœ… User can read ebooks (EPUB) with adjustable typography - βœ… User can read comics (CBZ/CBR/PDF) with panel zoom - βœ… User can read manga with RTL and vertical scroll modes - βœ… Progress syncs across devices via WebSocket - βœ… User can create bookmarks, highlights, notes - βœ… User can look up words in dictionary (offline) - βœ… Reader works offline for cached content - βœ… Settings persist across devices (DB) and browsers (localStorage) - βœ… 8 bundled libre reading fonts (no network requests) - βœ… UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes ### 18.2 Performance Requirements - ⚑ Initial page load: < 2 seconds - ⚑ Page turn (comics): < 500ms with 5-page cache - ⚑ Panel zoom animation: 300ms smooth - ⚑ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached) - ⚑ Offline cache hit: < 100ms ### 18.3 Quality Requirements - βœ… Zero TypeScript errors - βœ… All integration tests passing - βœ… Zero known security vulnerabilities - βœ… Mobile-responsive (320px - 4K) - βœ… Keyboard accessible - βœ… WCAG 2.1 AA compliant --- ## 19. Future Enhancements (Out of Scope for Initial Implementation) - TTS (Text-to-Speech) - user excluded - Advanced ML panel detection with custom model - Social features (share highlights, see friends' progress) - Advanced annotations (draw on pages, voice notes) - PDF form filling - EPUB audio/video media overlays - Advanced manga panel navigation (auto-detect panel order) - Reading goals and challenges - Social reading (book clubs, shared annotations) --- ## Conclusion This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The **universal reader architecture with pluggable parsers** provides the best balance of code reuse, maintainability, and extensibility. **Key principles:** - **Universal reader**: One rendering engine for all reflowable ebooks (EPUB, FB2, TXT, HTML, MOBI, AZW3, DOCX, RTF) - **Common Intermediate Format (CIF)**: Standardized HTML structure that all parsers produce - **Hybrid parsing**: Client-side for simple formats (~500 KB), server-side for complex formats (no 182 MB Calibre dependency) - **Procedural TypeScript**: Functions, not classes (per PROJECT_GUIDELINES.md) - **Surgical code reuse**: Extend existing systems (WebSocket sync, progress tracking, annotations) - **Progressive enhancement**: SSR-first with TypeScript enhancements - **Privacy-first**: Per-user settings with localStorage fallback - **Offline-capable**: PWA with service worker - **Libre fonts only**: 8 bundled open-source reading fonts - **Hybrid theming**: 11 themes for UI/comics, 5 reading-optimized themes for ebook text **Supported Formats:** | Format | Parser Location | Dependency Size | Status | |--------|-----------------|-----------------|--------| | **EPUB 2/3** | Client (TypeScript) | 0 KB (JSZip) | βœ… Planned | | **FB2** | Client (TypeScript) | 0 KB (XML) | βœ… Planned | | **TXT** | Client (TypeScript) | 0 KB | βœ… Planned | | **HTML** | Client (TypeScript) | 0 KB | βœ… Planned | | **MOBI** | Server (Go) | ~100 KB | βœ… Planned | | **AZW3** | Server (Go) | ~50 KB | βœ… Planned | | **DOCX** | Server (Go) | ~200 KB (mammoth) | βœ… Planned | | **RTF** | Server (Go) | ~50 KB | βœ… Planned | | **PDF** | Client (pdf.js) | ~500 KB | βœ… Planned | | **Comics** | Client (canvas) | 0 KB | βœ… Planned | | **Manga** | Client (extends comics) | 0 KB | βœ… Planned | **Total client-side dependencies: ~1 MB (vs. 182 MB for Calibre)** **Key design decisions:** - **Architecture**: Universal reader + parser pipeline (not separate readers) - **Parsing**: Hybrid (client for simple, server for complex) - **Code style**: Procedural TypeScript (no OOP per guidelines) - **Fonts**: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only - **Theming**: Hybrid - 11 themes for UI, 5 reading-optimized themes for text - **Typography**: Optimized for extended reading (Literata default) **Estimated timeline:** 8 weeks for full implementation **Next steps:** 1. Review and approve this plan 2. Begin Phase 1: Infrastructure & Basic Reader 3. Create database schema (add media_bookmarks table with chapter_number and cfi_position fields) 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*