Files
bookhoard/READER_IMPLEMENTATION_PLAN.md
T
john-okeefe 3228b62ea3 docs: update reader plan - Phase 0 prerequisites section
Update READER_IMPLEMENTATION_PLAN.md to include comprehensive Phase 0
setup and verification guide.

## Phase 0: Prerequisites & Setup (NEW Section)

### 0.1 Database Schema Setup
- References Section 2.1 for SQL (no duplication)
- Step-by-step instructions for schema.sql modifications
- Database recreation vs manual migration options
- sqlc generate commands for Go code regeneration

### 0.2 Database Queries Setup
- 12 new SQL queries for reader functionality
- Complete query definitions with parameter types
- Instructions for regeneration with sqlc

### 0.3 Frontend Dependencies
- jszip@^3.10.1 for EPUB/comic archive parsing
- pdfjs-dist@^3.11.174 for PDF rendering
- npm install instructions

### 0.4 Directory Structure
- mkdir commands for reader components (ebook, comic, manga, pdf)
- Font directory for bundled reading fonts
- Bruno API test directory

### 0.5 Pre-Implementation Checklist
- 10-item verification checklist
- Database, frontend, and structure items

### 0.6 Verification Commands
- Database table existence check
- Query generation verification
- Frontend dependency verification
- Go build verification
- Directory structure verification

### 0.7 Troubleshooting
- Common setup problems and solutions
- sqlc generate failures
- Database table issues
- npm install problems
- Go compilation errors

## Rationale

This prevents developers from starting implementation without
the necessary foundation, which would result in:
- Compilation errors from missing database queries
- Runtime errors from missing database tables
- Frontend build errors from missing dependencies
- Lost time from having to stop and fix prerequisites

## Changes

- Added Phase 0 section (~415 lines)
- Updated database references
- Added verification commands
- Added troubleshooting guide

Total: 1 file changed, 24 insertions(+), 37 deletions(-)

Phase 0 is now complete and ready for implementation to begin.
2026-04-02 21:02:08 -04:00

11125 lines
318 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD013 -->
# 📖 Bookhoard Reader Implementation Plan
## Overview
Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types.
**Design Philosophy:**
- **Universal reader architecture**: One rendering engine with pluggable parser components
- **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure
- **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT)
- **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md)
- **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems
- **Progressive enhancement**: SSR-first with TypeScript enhancements
- **Privacy-first**: Per-user settings with localStorage fallback
- **Offline-capable**: PWA with offline dictionary
- **Full PDF support**: Mozilla pdf.js for text selection, highlights, search
- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, mini-map, copy support
## What's New in This Version
### **Major Architecture Change: Universal Reader + Parsers**
**Previous approach:** Separate readers for each format (EbookReader, ComicReader, etc.)
**New approach:** Single universal reader with parser pipeline
```
All Reflowable Ebooks → Parse to CIF → Universal Reader
├── EPUB → EPUBParser → CIF → Universal Reader
├── FB2 → FB2Parser → CIF → Universal Reader
├── TXT → TXTParser → CIF → Universal Reader
├── HTML → HTMLParser → CIF → Universal Reader
├── MOBI → Server Parser → CIF → Universal Reader
├── AZW3 → Server Parser → CIF → Universal Reader
├── DOCX → Server Parser → CIF → Universal Reader
└── RTF → Server Parser → CIF → Universal Reader
```
**Benefits:**
- One codebase for UI/UX (fix once, works for all formats)
- Easy to add new formats (just implement parser interface)
- Consistent user experience across all ebooks
- ~500 KB total dependency size (vs. 182 MB Calibre)
### **Procedural TypeScript (No OOP)**
All code follows PROJECT_GUIDELINES.md:
- ❌ No classes
- ❌ No `this` capture
- ❌ No inheritance
- ✅ Functions and modules
- ✅ Functional techniques where helpful
- ✅ Procedural/imperative style
**Example:**
```typescript
// ❌ OLD (OOP - not allowed)
class EPUBParser {
private zip: JSZip | null = null;
async parse(blob: Blob): Promise<CIF> { ... }
}
// ✅ NEW (Procedural - correct)
export async function parseEPUB(blob: Blob): Promise<CIF> { ... }
```
---
## 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
├── progress-tracker.ts - Integration with reading_progress table
├── annotation-manager.ts - Integration with notes/highlights tables
├── websocket-sync.ts - Reuse existing sync system
├── settings-manager.ts - Per-user preferences (DB + localStorage)
├── bookmark-manager.ts - Integration with existing bookmarks
└── chapter-detector.ts - Chapter detection for all media types
Universal Ebook Reader (Reflowable Formats)
├── html-renderer.ts - Browser-native HTML rendering (shared)
├── typography-engine.ts - Font rendering, theme integration (shared)
├── cfi-navigator.ts - Universal position navigation
├── dictionary-popup.ts - Offline dictionary lookup
Parser Pipeline
├── parser-manager.ts - Routes format → appropriate parser
├── cif-types.ts - Common Intermediate Format types
├── Client-Side Parsers (TypeScript)
│ ├── epub-parser.ts - EPUB 2/3 parsing (ZIP + XML)
│ ├── fb2-parser.ts - FictionBook 2 parsing (XML)
│ ├── txt-parser.ts - Plain text wrapper
│ └── html-parser.ts - Standalone HTML files
└── Server-Side Parsers (Go backend)
├── mobi-parser.go - MOBI parsing
├── azw3-parser.go - AZW3/KF8 parsing
├── docx-parser.go - Word document parsing
└── rtf-parser.go - Rich Text Format parsing
PDF Reader (Fixed Layout)
├── pdfjs-wrapper.ts - Mozilla pdf.js integration
├── text-layer-renderer.ts - Text layer overlay for selection
├── annotation-layer.ts - Highlight/note rendering
├── pdf-navigation.ts - Page navigation, zoom, fit modes
├── pdf-search.ts - Full-text search within PDF
├── page-cache.ts - 5-page ahead cache
├── text-selection.ts - Text selection and highlight creation
├── pdf-outline.ts - TOC navigation
├── pdf-bookmarks.ts - Custom bookmarks
├── pdf-clipboard.ts - Copy to clipboard
├── pdf-links.ts - Internal link handling
├── pdf-dual-page.ts - Dual page spread view
├── pdf-minimap.ts - Mini-map navigation
├── pdf-rotation.ts - Rotated page support
└── pdf-page-sizes.ts - Variable page size handling
Comic Reader (Image Archives)
├── image-archive-parser.ts - CBZ/CBR parsing
├── canvas-renderer.ts - Canvas rendering with lazy loading
├── panel-detector.ts - Grid-based + ML + manual override
├── panel-navigator.ts - Panel zoom with smooth animations
└── page-cache.ts - 5-page ahead cache
Manga Reader (extends Comic)
├── rtl-navigator.ts - Right-to-left navigation
├── vertical-scroll-mode.ts - Webtoon-style vertical scroll
└── panel-detector.ts - Manga-aware panel detection
```
### 1.3 Theming Strategy (Hybrid Approach)
**Design Decision:**
Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices:
```
┌─────────────────────────────────────────────────────────┐
│ UI Chrome (Bars, Panels, Settings) │
│ ✅ All 11 Bookhoard themes available │
│ - tokyo-night, dracula, nord, etc. │
│ - Maintains consistency with rest of app │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Ebook Text Content │
│ ✅ 5 reading-optimized themes only │
│ - Light (standard) │
│ - Sepia (warm, easier on eyes) │
│ - Dark (reduced eye strain) │
│ - Night (reduced blue light for better sleep) │
│ - High Contrast (accessibility) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ PDF Documents │
│ ✅ 5 reading-optimized themes only │
│ - Light, Sepia, Dark, Night, High Contrast │
│ - PDF.js supports custom CSS for text layer │
│ - Maintains readability for long documents │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Comic/Manga Images │
│ ✅ All 11 Bookhoard themes available │
│ - Visual content works well with any theme │
│ - No eye fatigue concerns with images │
└─────────────────────────────────────────────────────────┘
```
**Why This Approach?**
1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization
2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes
3. **Accessibility**: Reading-optimized themes help users with visual impairments
4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions
5. **Flexibility**: Still have full theming for UI and visual content
**Popular Ebook Reader Comparison:**
| Reader | Reading Themes | Color Options? |
|-------------|----------------|----------------|
| Kindle | 4 | No |
| Kobo | 4 | No (green for night) |
| Apple Books | 5 | No |
| **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** |
**Implementation:**
- `chrome_theme`: Applied to reader shell, navigation bars, settings panels
- `reading_theme`: Applied to ebook text content only (5 options)
- Comics/manga: Use `chrome_theme` (all 11 themes work well)
### 1.4 Data Flow
```
User opens reader
Backend: GET /api/readers/:mediaItemId
Verify access, fetch metadata, progress, bookmarks
SSR render: templates/reader.templ with initial data
Frontend: Initialize appropriate reader (Ebook/PDF/Comic/Manga)
Load content (lazy load + cache)
User interacts (turn page, highlight, bookmark)
Real-time sync via WebSocket (reuse existing system)
```
---
## 2. Database Schema Changes
### 2.1 New Tables
```sql
-- Panel detection data
CREATE TABLE 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
---
## 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("<div class='ebook-page'><p>Page %d content</p></div>", 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("<div class='pdf-page'><p>Page %d content</p></div>", 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<string, Blob>;
// 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<EbookCIF>;
extractMetadata(file: Blob): Promise<Partial<EbookCIF['metadata']>>;
}
// ============================================================
// 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;
}
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<void> {
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<UniversalReader> {
// 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<string, string> = {
'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: <w:document><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>
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 "<html><body><p>MOBI content extraction requires full parser implementation</p></body></html>"
}
// 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:
// - <metadata> section for title, author, etc.
// - <manifest> for resource list
// - <spine> for reading order
// - <guide> 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
// <w:p> elements contain paragraphs
// <w:r> elements contain runs
// <w:t> 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
// <dc:title>, <dc:creator>, <dc:description>, 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("<html><body>")
for _, p := range paragraphs {
text, _ := p["text"].(string)
bold, _ := p["bold"].(bool)
italic, _ := p["italic"].(bool)
html.WriteString("<p")
if bold {
html.WriteString(" style='font-weight: bold'")
}
if italic {
html.WriteString(" style='font-style: italic'")
}
html.WriteString(">")
html.WriteString(text)
html.WriteString("</p>")
}
html.WriteString("</body></html>")
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("<br>")
case "tab":
text.WriteString("&nbsp;&nbsp;&nbsp;&nbsp;")
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><body><p>")
html.WriteString(text)
html.WriteString("</p></body></html>")
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<ReaderSettings>;
save(settings: Partial<ReaderSettings>): Promise<void>;
sync(): Promise<void>; // Sync localStorage → DB
get(key: keyof ReaderSettings): any;
set(key: keyof ReaderSettings, value: any): Promise<void>;
}
async function loadSettings(): Promise<ReaderSettings> {
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<ReaderSettings>): Promise<void> {
const token = getToken();
if (!token) {
// Save to localStorage only
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut('/readers/settings', settings);
// Update localStorage cache
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
// Fallback to localStorage
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: 'auto-hide',
progress_mode: 'pages',
chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available
reading_theme: 'dark', // Ebook text: 5 reading-optimized themes
reading_font: 'literata', // Default reading font (designed for ebooks)
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
font_size: 16,
line_height: 1.6,
margin_width: 20,
double_page_spread: false,
reading_direction: 'ltr',
hardware_acceleration: true
};
}
```
---
## 5. Ebook Reader Implementation
### 5.1 Parser Manager (Procedural)
**File:** `web/src/reader/parser-manager.ts`
```typescript
// Parser Manager - Routes files to appropriate parsers
// Procedural style: Functions, not classes
import JSZip from 'jszip';
// ============================================================
// Parser Registry
// ============================================================
const PARSER_REGISTRY: ParserEntry[] = [
{ format: 'epub', mimeType: 'application/epub+zip', extensions: ['.epub'], side: 'client' },
{ format: 'fb2', mimeType: 'application/fb2', extensions: ['.fb2', '.fb2.zip'], side: 'client' },
{ format: 'txt', mimeType: 'text/plain', extensions: ['.txt'], side: 'client' },
{ format: 'html', mimeType: 'text/html', extensions: ['.html', '.htm'], side: 'client' },
{ format: 'mobi', mimeType: 'application/x-mobipocket-ebook', extensions: ['.mobi', '.azw'], side: 'server' },
{ format: 'azw3', mimeType: 'application/vnd.amazon.mobi8-ebook', extensions: ['.azw3'], side: 'server' },
{ format: 'docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'], side: 'server' },
{ format: 'rtf', mimeType: 'application/rtf', extensions: ['.rtf'], side: 'server' },
];
interface ParserEntry {
format: string;
mimeType: string;
extensions: string[];
side: 'client' | 'server';
}
// ============================================================
// Parser Detection
// ============================================================
export function detectParserFormat(mimeType: string, extension: string): ParserEntry | null {
return PARSER_REGISTRY.find(entry =>
entry.mimeType === mimeType ||
entry.extensions.includes(extension.toLowerCase())
) || null;
}
export function requiresServerParsing(mimeType: string, extension: string): boolean {
const entry = detectParserFormat(mimeType, extension);
return entry?.side === 'server' || false;
}
// ============================================================
// Main Parse Function (Router)
// ============================================================
export async function parseEbook(file: Blob, mimeType: string, extension: string): Promise<EbookCIF> {
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<EbookCIF> {
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<EbookCIF> {
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<EbookCIF> {
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<string> {
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<EbookCIF['toc']> {
// 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<EbookCIF['toc']> {
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<EbookCIF['toc']> {
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<Map<string, Blob>> {
const resources = new Map<string, Blob>();
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<Blob | undefined> {
// 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<string, Blob>): Promise<number> {
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<Partial<EbookCIF['metadata']>> {
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<EbookCIF> {
// 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 = `<div id="section-${index}" class="fb2-section">`;
if (title) {
html += `<h1>${title.textContent}</h1>`;
}
// Convert paragraphs
const paragraphs = section.querySelectorAll('p');
paragraphs.forEach(p => {
html += `<p>${p.innerHTML}</p>`;
});
// Convert images
const images = section.querySelectorAll('image');
images.forEach(img => {
const href = img.getAttribute('l:href');
const alt = img.getAttribute('alt') || '';
if (href) {
html += `<img src="${href}" alt="${alt}" />`;
}
});
html += '</div>';
return html;
}
async function extractFB2Resources(xmlDoc: XMLDocument, fb2Blob: Blob): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
// 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<Partial<EbookCIF['metadata']>> {
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<EbookCIF> {
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 = '<div class="txt-content">';
lines.forEach(line => {
const trimmed = line.trim();
if (trimmed) {
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
} else {
htmlContent += '<br />';
}
});
htmlContent += '</div>';
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<Partial<EbookCIF['metadata']>> {
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<EbookCIF> {
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<Map<string, Blob>> {
const resources = new Map<string, Blob>();
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<Partial<EbookCIF['metadata']>> {
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<void> {
// 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<string, Record<string, string>> = {
'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<void> {
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<void> {
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<string, string> = {
'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<SearchResult[]> {
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<Document | null> {
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<string>();
async function renderDocument(
container: HTMLElement,
doc: HTMLDocument,
config: RendererConfig
): Promise<void> {
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<string, Record<string, string>> = {
'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<void> {
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<void> {
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>
): 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 `<head>` section:
```go
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="bg-white dark:bg-gray-900" data-media-item-id={ metadata.id }>
<!-- Reader Chrome (Navigation Bars) -->
<div id="reader-chrome" class="reader-chrome bg-gray-100 dark:bg-gray-800 border-b border-gray-300 dark:border-gray-700">
<!-- Top Navigation Bar -->
<header class="flex items-center justify-between px-4 py-2">
<div class="flex items-center space-x-4">
<button
hx-get="/library"
hx-push-url="true"
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Back to Library
</button>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
{ metadata.title }
</h1>
</div>
<div class="flex items-center space-x-4">
<!-- Progress Indicator -->
<div id="progress-indicator" class="text-sm text-gray-600 dark:text-gray-400">
<span id="current-position">--</span> / <span id="total-position">--</span>
</div>
<!-- Settings Button -->
<button
x-data="{ open: false }"
@click="open = !open"
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Settings
</button>
</div>
</header>
<!-- Table of Contents Panel (Slide-in) -->
<div id="toc-panel" class="hidden fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-900 shadow-lg z-50">
<div class="p-4">
<h2 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Table of Contents</h2>
<nav id="toc-list" class="space-y-2">
<!-- TOC items will be populated by JavaScript -->
</nav>
</div>
</div>
</div>
<!-- Reader Content Area -->
<main id="reader-content" class="flex-1 overflow-auto">
<!-- Content will be rendered here by the appropriate reader -->
</main>
<!-- Bottom Navigation Bar -->
<footer class="fixed bottom-0 left-0 right-0 bg-gray-100 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 p-4">
<div class="flex items-center justify-between max-w-4xl mx-auto">
<button
id="prev-button"
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<div class="flex-1 mx-4">
<input
type="range"
id="position-slider"
min="0"
max="100"
value="0"
class="w-full"
/>
</div>
<button
id="next-button"
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</footer>
<!-- Settings Panel (Slide-in) -->
<div id="settings-panel" class="hidden fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-900 shadow-lg z-50 overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Reader Settings</h2>
<button id="close-settings" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
</button>
</div>
<!-- Font Settings -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font</h3>
<select id="font-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="literata">Literata (Default)</option>
<option value="crimson">Crimson Text</option>
<option value="source-serif">Source Serif 4</option>
<option value="eb-garamond">EB Garamond</option>
<option value="libertinus">Libertinus Serif</option>
<option value="noto-serif">Noto Serif</option>
<option value="charis-sil">Charis SIL</option>
<option value="ibm-plex">IBM Plex Serif</option>
</select>
</div>
<!-- Font Size -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font Size: <span id="font-size-value">16px</span></h3>
<input
type="range"
id="font-size-slider"
min="12"
max="24"
value="16"
class="w-full"
/>
</div>
<!-- Theme -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reading Theme</h3>
<div class="grid grid-cols-2 gap-2">
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="light">
Light
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="sepia">
📜 Sepia
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="dark">
🌙 Dark
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="night">
Night
</button>
</div>
</div>
<!-- Line Height -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Line Spacing</h3>
<select id="line-height-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="1.4">Compact</option>
<option value="1.6" selected>Normal</option>
<option value="1.8">Relaxed</option>
<option value="2.0">Loose</option>
</select>
</div>
<!-- Margins -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Page Margins</h3>
<select id="margin-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="narrow">Narrow</option>
<option value="medium" selected>Medium</option>
<option value="wide">Wide</option>
</select>
</div>
</div>
</div>
<!-- JavaScript Bundle -->
<script src="/static/reader.js"></script>
</body>
</html>
}
```
#### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended)
If you don't want to bundle fonts (slower initial load, privacy concerns):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Literata:wght@0,400;0,700;1,400&family=Libertinus+Serif:wght@0,400;0,700;1,400&family=Noto+Serif:wght@0,400;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
```
**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
<link rel="preload" href="/static/fonts/literata/Literata-400.woff2" as="font" type="font/woff2" crossorigin>
// Preload user's preferred font (from settings)
<link rel="preload" href="/static/fonts/crimson/CrimsonText-400.woff2" as="font" type="font/woff2" crossorigin>
```
**Lazy-load other fonts:**
```typescript
// Load other fonts on demand
async function loadFont(fontId: string): Promise<void> {
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<void> {
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>
): 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<SearchResult[]> {
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<Document | null> {
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<boolean> {
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 = `
<button class="prev-page" ${currentPage === 1 ? 'disabled' : ''}>← Previous</button>
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
<button class="next-page" ${currentPage === pageCount ? 'disabled' : ''}>Next →</button>
`;
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<number, pdfjsLib.PDFPageProxy>;
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<PDFMetadata> {
// 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<pdfjsLib.PDFPageProxy> {
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<any> {
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<string, { highlight: string }> = {
'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<string, HTMLElement>();
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<PDFSearchState> {
return {
doc,
searchResults: [],
currentResultIndex: 0
};
}
async function searchPDF(state: PDFSearchState, query: string): Promise<PDFSearchState> {
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<number, CachedPage>;
maxCacheSize: number;
renderCallbacks: Map<number, Array<() => 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<PDFPageCacheState & { page: CachedPage }> {
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<Highlight> {
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<void> {
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}<mark style="background-color: ${addAlphaToColor(color, 0.4)}">${selection}</mark>${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<string, number>;
}
async function initializePDFOutline(doc: PDFDocumentProxy): Promise<PDFOutlineState> {
const state: PDFOutlineState = {
doc,
outline: [],
flatMap: new Map()
};
return await loadPDFOutline(state);
}
async function loadPDFOutline(state: PDFOutlineState): Promise<PDFOutlineState> {
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<PDFOutlineNode[]> {
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<number> {
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<string, number> {
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<MediaBookmarksState> {
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<MediaBookmarksState & { bookmark: MediaBookmark }> {
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<MediaBookmarksState> {
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<boolean> {
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<PDFLinkHandlerState> {
const state: PDFLinkHandlerState = {
doc,
container,
onPageNavigate
};
await setupPDFLinks(state);
return state;
}
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
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<void> {
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<number> {
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<number, HTMLCanvasElement>;
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 = `
<div class="pdf-minimap-header">Pages</div>
<div class="pdf-minimap-thumbnails"></div>
<div class="pdf-minimap-indicator"></div>
`;
const style = document.createElement('style');
style.textContent = getMiniMapStyles();
miniMap.appendChild(style);
return miniMap;
}
async function initializePDFMiniMap(
state: PDFMiniMapState,
totalPages: number,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
): Promise<PDFMiniMapState> {
const newState = { ...state, totalPages };
await generateMiniMapThumbnails(newState, renderThumbnail);
setupMiniMapEventListeners(newState);
return newState;
}
async function generateMiniMapThumbnails(
state: PDFMiniMapState,
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
): Promise<void> {
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails') as HTMLElement;
container.innerHTML = '';
for (let page = 1; page <= state.totalPages; page++) {
try {
const thumbnail = await renderThumbnail(page);
thumbnail.className = 'pdf-minimap-thumbnail';
thumbnail.dataset.pageNumber = page.toString();
thumbnail.style.width = '80px';
thumbnail.style.height = 'auto';
thumbnail.style.cursor = 'pointer';
thumbnail.style.marginBottom = '4px';
container.appendChild(thumbnail);
state.thumbnails.set(page, thumbnail);
} catch (error) {
console.error(`Failed to generate thumbnail for page ${page}:`, error);
}
}
}
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails');
container?.addEventListener('click', (e) => {
const thumbnail = (e.target as HTMLElement).closest('.pdf-minimap-thumbnail') as HTMLElement;
if (thumbnail) {
const pageNumber = parseInt(thumbnail.dataset.pageNumber || '1');
state.onPageNavigate(pageNumber);
}
});
}
function updateMiniMapCurrentPage(state: PDFMiniMapState, pageNumber: number): PDFMiniMapState {
const indicator = state.miniMap.querySelector('.pdf-minimap-indicator') as HTMLElement;
const thumbnail = state.thumbnails.get(pageNumber);
if (thumbnail && indicator) {
const rect = thumbnail.getBoundingClientRect();
indicator.style.top = `${thumbnail.offsetTop}px`;
indicator.style.height = `${rect.height}px`;
}
state.thumbnails.forEach((thumb, page) => {
if (page === pageNumber) {
thumb.style.outline = '2px solid var(--accent)';
thumb.style.opacity = '1';
} else {
thumb.style.outline = 'none';
thumb.style.opacity = '0.7';
}
});
return { ...state, currentPage: pageNumber };
}
function showMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = 'block';
}
function hideMiniMap(state: PDFMiniMapState): void {
state.miniMap.style.display = 'none';
}
function toggleMiniMap(state: PDFMiniMapState): void {
const isVisible = state.miniMap.style.display !== 'none';
state.miniMap.style.display = isVisible ? 'none' : 'block';
}
function getMiniMapStyles(): string {
return `
.pdf-minimap {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 100px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--text-secondary);
border-radius: 8px;
padding: 8px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.pdf-minimap-header {
font-size: 12px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--text-primary);
}
.pdf-minimap-thumbnails {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pdf-minimap-thumbnail {
transition: outline 0.2s, opacity 0.2s;
border-radius: 2px;
}
.pdf-minimap-thumbnail:hover {
opacity: 1 !important;
outline: 1px solid var(--text-secondary) !important;
}
.pdf-minimap-indicator {
position: absolute;
left: 0;
right: 0;
border-left: 3px solid var(--accent);
pointer-events: none;
transition: top 0.3s ease-out;
}
`;
}
```
### 6.14 PDF Rotated Page Support
**File:** `web/src/reader/pdf/pdf-rotation.ts`
```typescript
// Handle rotated/landscape pages in PDFs
// Procedural implementation (no OOP)
interface PDFRotationState {
rotations: Map<number, number>;
}
function createPDFRotation(): PDFRotationState {
return {
rotations: new Map()
};
}
async function loadPDFPageRotations(
state: PDFRotationState,
doc: any
): Promise<PDFRotationState> {
const rotations = new Map<number, number>();
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<number, PageInfo>;
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<PDFPageSizesState> {
const pageSizes = new Map<number, PageInfo>();
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<string, { width: number; height: number; count: number }> = 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<number>();
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<void> {
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<Panel[]> {
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<void> {
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 = `
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">F</kbd> Fullscreen</div>
<div><kbd class="bg-gray-700 px-2 py-1 rounded">1-9</kbd> Jump to %</div>
</div>
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
Close
</button>
</div>
`;
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 = `
<span class="page-label">${state.currentPage}</span>
<input
type="range"
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
min="1"
max="${state.totalPages}"
value="${state.currentPage}"
/>
<span class="page-total">${state.totalPages}</span>
`;
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 = `
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
<span class="text-center text-sm">${state.gapSize}px</span>
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
${state.showBorders ? '▦' : '▢'}
</button>
`;
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<BackgroundColor, string> = {
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 = `
<span class="chapter-number ${isCurrentChapter ? 'text-blue-400 font-bold' : ''}">
${chapter.title || `Chapter ${chapter.chapterNumber}`}
</span>
<span class="page-number text-xs">p.${chapter.pageStart}</span>
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ''}
`;
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<number, HTMLImageElement>;
loading: Set<number>;
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<PageCacheState & { page: HTMLImageElement }> {
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<PageCacheState & { page: HTMLImageElement }>;
}
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<HTMLImageElement> {
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 = '<p class="text-sm">Loading...</p>';
document.body.appendChild(popup);
// Look up word
lookupWord(word).then((entry) => {
popup.innerHTML = `
<h3 class="font-bold text-lg">${entry.word}</h3>
<p class="text-sm italic">${entry.part_of_speech || ''}</p>
<p class="mt-2">${entry.definition}</p>
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ''}
`;
}).catch((error) => {
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
});
// 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<void> {
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) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
x-data="readerShell"
x-init="initReader()"
class="theme-{ user.Theme }"
data-media-item-id={ metadata.media_item_id }
data-media-type={ metadata.library_type }
data-total-pages={ metadata.total_pages }
>
@ReaderChrome(user, metadata, progress)
<main id="reader-content" class="w-full h-full">
<!-- Content loaded by JavaScript based on media type -->
</main>
@ReaderSettingsPanel()
@ReaderTOCPanel(metadata)
@DictionaryPopup()
</body>
</html>
}
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300">
<!-- Top bar -->
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<a href="/media-items/{ metadata.media_item_id }" class="text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.title }</h1>
<button
data-action="open-settings"
class="p-2 rounded-lg hover:bg-gray-700"
title="Settings"
>
</button>
</div>
</div>
<!-- Bottom bar -->
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<div id="progress-display" data-progress-mode="pages">
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
</div>
<div class="flex items-center gap-4">
<button data-action="toggle-toc" title="Table of Contents">📖</button>
<button data-action="add-bookmark" title="Bookmark">🏷</button>
<button data-action="add-note" title="Note">📝</button>
</div>
</div>
</div>
</div>
}
templ ReaderSettingsPanel() {
<div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<!-- Display settings -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Display</h3>
<label class="block mb-2">
Chrome Behavior
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
<option value="auto-hide">Auto Hide</option>
<option value="always-visible">Always Visible</option>
<option value="hide-on-scroll">Hide on Scroll</option>
</select>
</label>
<label class="block mb-2">
Progress Mode
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
<option value="pages">Pages</option>
<option value="chapter">Chapter</option>
<option value="percentage">Percentage</option>
<option value="time-left">Time Left</option>
</select>
</label>
</div>
<!-- Typography (ebooks only) -->
<div class="mb-6" data-visible-for="ebook">
<h3 class="font-semibold mb-2">Typography</h3>
<label class="block mb-2">
Reading Font
<select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
<option value="literata">Literata (Default - Designed for ebooks)</option>
<option value="crimson">Crimson Text (Screen-optimized)</option>
<option value="source-serif">Source Serif 4 (Adobe quality)</option>
<option value="eb-garamond">EB Garamond (Classic)</option>
<option value="libertinus">Libertinus Serif (Technical)</option>
<option value="noto-serif">Noto Serif (All languages)</option>
<option value="charis-sil">Charis SIL (Multilingual)</option>
<option value="ibm-plex">IBM Plex Serif (Modern)</option>
</select>
<p class="text-xs mt-1" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
</label>
<label class="block mb-2">
Font Size
<input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
</label>
<label class="block mb-2">
Line Height
<input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
</label>
</div>
<!-- Navigation -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Navigation</h3>
<label class="flex items-center mb-2">
<input type="checkbox" name="panel_zoom_enabled" class="mr-2"/>
Panel Zoom (Comics/Manga)
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="double_page_spread" class="mr-2"/>
Double Page Spread (Comics/Manga)
</label>
</div>
<button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
}
templ ReaderTOCPanel(metadata ReaderMetadata) {
<div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
<div id="toc-content">
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
for _, chapter := range metadata.chapter_metadata.Chapters {
<a
href="#"
data-chapter-id={ chapter.ID }
class="block py-2 hover:bg-gray-700 rounded px-2"
>
{ chapter.Title }
</a>
}
} else {
<p class="text-sm">No chapters available</p>
}
</div>
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
Close
</button>
</div>
</div>
}
templ DictionaryPopup() {
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
}
```
---
## 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*