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.
318 KiB
📖 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
thiscapture - ❌ No inheritance
- ✅ Functions and modules
- ✅ Functional techniques where helpful
- ✅ Procedural/imperative style
Example:
// ❌ 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:
cd /home/nymusicman/Code/bookhoard/database
sqlc generate
Step 4: Update your local database:
Option 1: Recreate database (LOSES ALL DATA - Recommended for development):
podman compose down -v # Delete all volumes
podman compose up -d # Start with fresh schema
Option 2: Manual SQL migration (preserves data):
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:
-- 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:
cd /home/nymusicman/Code/bookhoard/database
sqlc generate
0.3 Frontend Dependencies
File: package.json (modify existing)
Add these dependencies to the dependencies section:
{
"dependencies": {
"jszip": "^3.10.1",
"pdfjs-dist": "^3.11.174"
}
}
Then install:
npm install
0.4 Directory Structure
Create the required directory structure:
# 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 generateto regenerate Go code - Database: Updated local database (recreated or migrated)
- Frontend: Added
jszipandpdfjs-disttopackage.json - Frontend: Ran
npm installto install dependencies - Structure: Created all required directories (mkdir commands above)
- Verification: Can run
go build ./...without errors - Verification: Database tables exist (check with
\dtin psql)
0.6 Verification Commands
Verify database tables exist:
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:
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:
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:
go build ./...
# Should complete without errors
Verify directories exist:
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:
-- 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:
cd /home/nymusicman/Code/bookhoard/database
sqlc generate
0.3 Frontend Dependencies
File: package.json (modify existing)
Add these dependencies to the dependencies section:
{
"dependencies": {
"jszip": "^3.10.1",
"pdfjs-dist": "^3.11.174"
}
}
Then install:
npm install
0.4 Directory Structure
Create the required directory structure:
# 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 generateto regenerate Go code - Database: Updated local database (recreated or migrated)
- Frontend: Added
jszipandpdfjs-disttopackage.json - Frontend: Ran
npm installto install dependencies - Structure: Created all required directories
- Verification: Can run
go build ./...without errors - Verification: Database tables exist (check with
\dtin psql)
0.6 Verification Commands
Verify database tables exist:
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:
grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go
# Should return count > 0
Verify frontend dependencies:
npm list jszip pdfjs-dist
# Should show versions, not "(empty)"
Verify Go compilation:
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?
- Code Reuse: One reader implementation for all reflowable ebooks
- Consistency: All formats have identical UI/UX
- Maintainability: Fix bug once, applies to all formats
- Extensibility: Add new format by implementing parser interface
- 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?
- Reading Science: Long-form reading (300+ pages) requires eye-comfort optimization
- User Expectations: Kindle, Kobo, Apple Books offer 3-5 reading themes
- Accessibility: Reading-optimized themes help users with visual impairments
- Best Practices: Unusual colors (purple text) cause eye fatigue over long sessions
- 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 panelsreading_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
-- 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
-- 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 CASCADEfor referential integrity
3. API Endpoints
3.1 Reader Routes
File: internal/router/reader.go (new file)
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):
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):
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.Queriesfor 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:
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:
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)
// ============================================================
// 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
// 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)
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(" ")
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
#!/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:
# 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:
# 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:
# 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:
# 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:
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):
<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):
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:
// 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:
// 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)
/* 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)
// 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-contentelements - 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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)
{
"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)
// 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:
{
"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
// 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
// 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)
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:
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:
// 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
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
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
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:
{
"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:
{
"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*