Add detailed 8-week implementation plan for ebook, comic, and manga reader with hybrid architecture, panel detection, offline support, and libre fonts. Key Features: - Hybrid reader architecture: Shared infrastructure + specialized components - Ebook reader: EPUB parsing, HTML rendering, CFI navigation, typography engine - Comic reader: Grid-based + ML + manual panel detection, zoom animations - Manga reader: RTL navigation, vertical scroll mode - Offline support: PWA with service worker, cached pages - Dictionary: Offline-capable word lookup - Annotations: Bookmarks, highlights, notes (reuses existing DB schema) - Reading statistics: Speed tracking, time estimation - KOReader-style progress: Pages, chapter-relative, percentage, time-left Theming Strategy: - UI Chrome: All 11 Bookhoard themes - Ebook text: 5 reading-optimized themes (light, sepia, dark, night, high-contrast) - Comics/manga: All 11 themes (visual content works well with colors) Typography: - 8 bundled libre fonts (~1.2MB WOFF2): Literata, Crimson Text, Source Serif 4, EB Garamond, Libertinus, Noto Serif, Charis SIL, IBM Plex - Standard weights only (400, 400i, 700, 700i) - Font acquisition script included - Offline-ready (no network requests for fonts) Database Schema: - panel_data: Hybrid panel detection (grid/ML/manual) - reading_speed: Per-user reading speed tracking - dictionary_cache: Offline dictionary support - reader_settings: Per-user preferences (DB + localStorage fallback) API Endpoints: - 10 new reader routes (/api/readers/*) - Lazy-loading page cache (5-page ahead) - WebSocket integration for real-time sync - Bruno API tests included Phased Timeline: - Week 1-2: Infrastructure & basic reader - Week 3-4: Comic/manga features - Week 5-6: Advanced features - Week 7: Offline support - Week 8: Polish & testing Code Reuse: - Reuses existing WebSocket sync, progress tracking, annotations - Surgical code edits (extend, don't duplicate) - Follows existing Bookhoard patterns and conventions Includes: - Database schema changes (SQL) - API endpoint specifications - TypeScript type definitions - Frontend architecture (SSR-first with Alpine.js) - Integration test patterns - Bruno OpenCollection YAML tests - User and developer documentation locations Estimated total implementation time: 8 weeks
113 KiB
📖 Bookhoard Reader Implementation Plan
Overview
Build a modern, responsive web reader for ebooks, comics, and manga with full feature parity across all three media types.
Design Philosophy:
- Hybrid architecture: Shared infrastructure + specialized reader components
- Surgical code reuse: Leverage existing WebSocket sync, progress tracking, annotation systems
- Progressive enhancement: SSR-first with JavaScript enhancements
- Privacy-first: Per-user settings with localStorage fallback
- Offline-capable: PWA with offline dictionary
1. Architecture
1.1 Component Structure
Reader Infrastructure (Shared)
├── ReaderShell.ts - UI shell, chrome control, routing
├── ProgressTracker.ts - Integration with existing reading_progress table
├── AnnotationManager.ts - Integration with existing notes/highlights tables
├── WebSocketSync.ts - Reuse existing sync system
├── SettingsManager.ts - Per-user preferences (DB + localStorage)
├── BookmarkManager.ts - Integration with existing bookmarks
└── ChapterDetector.ts - Chapter detection for all media types
Medium-Specific Readers
├── EbookReader/
│ ├── EPUBParser.ts - Custom EPUB parsing (ZIP + XML)
│ ├── HTMLRenderer.ts - Browser-native HTML rendering
│ ├── CFINavigator.ts - EPUB CFI navigation (reuse existing sync/format.go logic)
│ ├── TypographyEngine.ts - Font rendering, theme integration
│ └── DictionaryPopup.ts - Offline dictionary lookup
│
├── ComicReader/
│ ├── ImageArchiveParser.ts - CBZ/CBR/PDF parsing
│ ├── CanvasRenderer.ts - Canvas rendering with lazy loading
│ ├── PanelDetector.ts - Grid-based + ML + manual override
│ ├── PanelNavigator.ts - Panel zoom with smooth animations
│ └── PageCache.ts - 5-page ahead cache
│
└── MangaReader/ (extends ComicReader)
├── RTLNavigator.ts - Right-to-left navigation
├── VerticalScrollMode.ts - Webtoon-style vertical scroll
└── PanelDetector.ts - Manga-aware panel detection
1.2 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) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 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.3 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/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 panel_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
panels JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, page_number)
);
CREATE INDEX idx_panel_data_media_item ON panel_data(media_item_id);
-- Reading speed tracking
CREATE TABLE reading_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
words_per_minute DECIMAL(6,2),
pages_per_minute DECIMAL(6,2),
pages_read INTEGER DEFAULT 0,
total_reading_minutes DECIMAL(8,2) DEFAULT 0,
last_read_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX idx_reading_speed_item ON reading_speed(media_item_id);
-- Dictionary cache (for offline use)
CREATE TABLE dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
word VARCHAR(100) NOT NULL UNIQUE,
definition TEXT NOT NULL,
part_of_speech VARCHAR(20),
example TEXT,
etymology TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
accessed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_dictionary_word ON dictionary_cache(word);
-- Reader settings (per-user preferences)
CREATE TABLE reader_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(50) NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, setting_key)
);
CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);
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
func registerReaderRoutes(cfg *Config) {
e := cfg.Echo
jwtMiddleware := createJWTMiddleware(cfg)
reader := e.Group("/readers", jwtMiddleware)
// Reader page (SSR)
reader.GET("/:mediaItemId", cfg.ReaderHandler.ShowReader)
// Content serving (lazy-loaded pages)
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
// Chapter metadata
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
// Panel data
reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) // Manual override
// 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)
}
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)
Key handler signatures:
type ReaderHandler struct {
db *database.Queries
libraryService *services.LibraryService
worker *services.Worker
}
func (h *ReaderHandler) ShowReader(c echo.Context) error
func (h *ReaderHandler) GetPage(c echo.Context) error
func (h *ReaderHandler) GetChapters(c echo.Context) error
func (h *ReaderHandler) GetPanels(c echo.Context) error
func (h *ReaderHandler) UpdatePanels(c echo.Context) error
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error
func (h *ReaderHandler) LookupWord(c echo.Context) error
func (h *ReaderHandler) GetSettings(c echo.Context) error
func (h *ReaderHandler) UpdateSettings(c echo.Context) error
3.3 Service Layer
File: internal/services/reader_service.go (new file)
All business logic goes here, not in handlers:
type ReaderService struct {
db *database.Queries
worker *services.Worker
}
// Chapter detection for all media types
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error)
// Panel detection (grid-based, ML, manual)
func (s *ReaderService) DetectPanels(ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string) ([]Panel, error)
// Reading speed calculation
func (s *ReaderService) CalculateReadingSpeed(ctx context.Context, userID, mediaItemID uuid.UUID, pagesRead int, minutes float64) error
// Dictionary lookup (with cache)
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error)
// Settings management (DB + localStorage sync)
func (s *ReaderService) GetSettings(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error)
func (s *ReaderService) UpdateSettings(ctx context.Context, userID uuid.UUID, settings map[string]interface{}) error
4. Frontend Implementation
4.1 File Structure
web/src/reader/
├── reader.ts - Main reader entry point
├── reader-shell.ts - UI shell, chrome control
├── progress-indicator.ts - KOReader-style switchable progress
├── settings-manager.ts - Settings (DB + localStorage)
├── slide-in-panel.ts - Shared slide-in panel (TOC + Settings)
├── annotation-manager.ts - Highlights, notes, bookmarks
├── websocket-sync.ts - Reuse existing websocket.ts
├── dictionary-popup.ts - Offline dictionary lookup
│
├── ebook/
│ ├── epub-parser.ts - EPUB parsing (ZIP + XML)
│ ├── html-renderer.ts - Browser-native rendering
│ ├── cfi-navigator.ts - EPUB CFI navigation
│ ├── typography-engine.ts - Font rendering, themes
│ └── chapter-detector.ts - Chapter detection
│
├── comic/
│ ├── image-parser.ts - CBZ/CBR/PDF parsing
│ ├── canvas-renderer.ts - Canvas rendering
│ ├── panel-detector.ts - Grid + ML + manual
│ ├── panel-navigator.ts - Panel zoom animations
│ └── page-cache.ts - 5-page ahead cache
│
└── manga/
├── rtl-navigator.ts - Right-to-left navigation
└── vertical-scroll.ts - Webtoon-style scroll
4.2 TypeScript Types
File: web/src/types/reader.d.ts (new file)
// Reader metadata (from API)
interface ReaderMetadata {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
library_type: 'ebook' | 'comic' | 'manga';
mime_type: string;
file_path: string;
chapter_metadata?: ChapterMetadata;
total_pages?: number;
}
// Chapter metadata
interface ChapterMetadata {
chapters: Chapter[];
}
interface Chapter {
id: string;
title: string;
start_page: number;
page_count: number;
}
// Panel data
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; // percentage (0-100)
y: number; // percentage (0-100)
width: number; // percentage (0-100)
height: number; // percentage (0-100)
reading_order: number;
}
// Reading speed
interface ReadingSpeed {
words_per_minute: number;
pages_per_minute: number;
pages_read: number;
total_reading_minutes: number;
last_read_at: string;
}
// Dictionary entry
interface DictionaryEntry {
word: string;
definition: string;
part_of_speech?: string;
example?: string;
etymology?: string;
}
// Reader settings
interface ReaderSettings {
// Display
chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll';
progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
// THEMING (Hybrid Approach)
// Chrome theme: Applied to reader UI (bars, panels, settings)
// Options: All 11 Bookhoard themes (tokyo-night, dracula, etc.)
chrome_theme: string;
// Reading theme: Applied to ebook text content only
// Options: Reading-optimized themes (light, sepia, dark, night, high-contrast)
// Comic/manga: Use chrome_theme (all 11 themes work well with visual content)
reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
// TYPOGRAPHY (Ebook Reading Fonts)
// 8 bundled libre fonts optimized for extended reading
// Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i)
// UI elements use Bookhoard's existing font stack (not these reading fonts)
reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
font_size: number;
line_height: number;
margin_width: number;
// Navigation
tap_zone_size: number; // Percentage (0-100)
auto_scroll: boolean;
panel_zoom_enabled: boolean;
// Manga/Comic specific
double_page_spread: boolean;
reading_direction: 'ltr' | 'rtl' | 'vertical';
// Advanced
hardware_acceleration: boolean;
}
// Progress indicator (KOReader-style)
interface ProgressDisplay {
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
current: number;
total: number;
label?: string; // e.g., "Chapter 5"
time_left?: string; // e.g., "2h 15m"
}
4.3 Reader Shell
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 | 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 '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.4 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.5 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 EPUB Parser
File: web/src/reader/ebook/epub-parser.ts
// EPUB parsing - ZIP + XML parsing for EPUB 2.0 and 3.0
interface EPUBMetadata {
title: string;
author: string;
language: string;
publisher?: string;
description?: string;
identifier?: string; // ISBN, UUID, etc.
}
interface EPUBSpineItem {
id: string;
href: string;
linear: string; // "yes" or "no"
properties?: string;
}
interface EPUBTableOfContents {
id: string;
label: string;
href: string;
children: EPUBTableOfContents[];
}
interface EPUBPackage {
metadata: EPUBMetadata;
spine: EPUBSpineItem[];
toc: EPUBTableOfContents[];
resources: Map<string, Blob>; // All files (HTML, CSS, images, fonts)
coverImage?: Blob;
}
class EPUBParser {
private zip: JSZip | null = null;
private packageDoc: XMLDocument | null = null;
async parse(epubBlob: Blob): Promise<EPUBPackage> {
// EPUB is a ZIP file
const zip = new JSZip();
this.zip = await zip.loadAsync(epubBlob);
// Parse container.xml to find OPF file
const containerXml = await this.getFileContent('META-INF/container.xml');
const containerDoc = this.parseXML(containerXml);
const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path');
if (!opfPath) {
throw new Error('Invalid EPUB: no OPF file found');
}
// Parse OPF file
const opfXml = await this.getFileContent(opfPath);
this.packageDoc = this.parseXML(opfXml);
// Extract metadata
const metadata = this.parseMetadata();
// Parse spine (reading order)
const spine = this.parseSpine();
// Parse table of contents
const toc = await this.parseTOC(opfPath);
// Load all resources
const resources = await this.loadResources();
// Extract cover image
const coverImage = await this.extractCover();
return {
metadata,
spine,
toc,
resources,
coverImage
};
}
private async getFileContent(path: string): Promise<string> {
if (!this.zip) throw new Error('EPUB not loaded');
const file = this.zip.file(path);
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file.async('text');
}
private parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
}
private parseMetadata(): EPUBMetadata {
if (!this.packageDoc) throw new Error('OPF not loaded');
const metadata = this.packageDoc.querySelector('metadata');
if (!metadata) {
throw new Error('No metadata found in OPF');
}
const title = metadata.querySelector('title')?.textContent || '';
const author = metadata.querySelector('creator')?.textContent || '';
const language = metadata.querySelector('language')?.textContent || 'en';
const publisher = metadata.querySelector('publisher')?.textContent || undefined;
const description = metadata.querySelector('description')?.textContent || undefined;
const identifier = metadata.querySelector('identifier')?.textContent || undefined;
return { title, author, language, publisher, description, identifier };
}
private parseSpine(): EPUBSpineItem[] {
if (!this.packageDoc) throw new Error('OPF not loaded');
const spine = this.packageDoc.querySelector('spine');
if (!spine) {
throw new Error('No spine found in OPF');
}
const manifest = this.packageDoc.querySelector('manifest');
if (!manifest) {
throw new Error('No manifest found in OPF');
}
const items: EPUBSpineItem[] = [];
const spineItems = spine.querySelectorAll('itemref');
spineItems.forEach((itemref) => {
const idref = itemref.getAttribute('idref');
if (!idref) return;
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
if (!manifestItem) return;
const href = manifestItem.getAttribute('href');
const linear = itemref.getAttribute('linear') || 'yes';
const properties = itemref.getAttribute('properties') || undefined;
if (href) {
items.push({ id: idref, href, linear, properties });
}
});
return items;
}
private async parseTOC(opfPath: string): Promise<EPUBTableOfContents[]> {
if (!this.packageDoc) throw new Error('OPF not loaded');
// Try EPUB 3.0 navigation document first
const navItem = this.packageDoc.querySelector('manifest item[properties~="nav"]');
if (navItem) {
const navHref = navItem.getAttribute('href');
if (navHref) {
const navPath = this.resolvePath(opfPath, navHref);
return this.parseNavTOC(navPath);
}
}
// Fallback to EPUB 2.0 NCX
const ncxId = this.packageDoc.querySelector('spine')?.getAttribute('toc');
if (ncxId) {
const ncxItem = this.packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) {
const ncxHref = ncxItem.getAttribute('href');
if (ncxHref) {
const ncxPath = this.resolvePath(opfPath, ncxHref);
return this.parseNCXTOC(ncxPath);
}
}
}
return [];
}
private async parseNavTOC(navPath: string): Promise<EPUBTableOfContents[]> {
const navXml = await this.getFileContent(navPath);
const navDoc = this.parseXML(navXml);
const nav = navDoc.querySelector('nav');
if (!nav) return [];
const items: EPUBTableOfContents[] = [];
const ol = nav.querySelector('ol');
if (ol) {
const lis = ol.querySelectorAll(':scope > li');
for (const li of lis) {
const link = li.querySelector('a');
if (link) {
const label = link.textContent || '';
const href = link.getAttribute('href') || '';
items.push({ id: href, label, href, children: [] });
}
}
}
return items;
}
private async parseNCXTOC(ncxPath: string): Promise<EPUBTableOfContents[]> {
const ncxXml = await this.getFileContent(ncxPath);
const ncxDoc = this.parseXML(ncxXml);
const navMap = ncxDoc.querySelector('navMap');
if (!navMap) return [];
return this.parseNCXNode(navMap);
}
private parseNCXNode(node: Element): EPUBTableOfContents[] {
const items: EPUBTableOfContents[] = [];
const navPoints = node.querySelectorAll(':scope > navPoint');
navPoints.forEach((navPoint) => {
const label = navPoint.querySelector('navLabel text')?.textContent || '';
const content = navPoint.querySelector('content');
const href = content?.getAttribute('src') || '';
const id = href;
const children = this.parseNCXNode(navPoint);
items.push({ id, label, href, children });
});
return items;
}
private async loadResources(): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
if (!this.zip) return resources;
// Load all files from the ZIP
const files = Object.keys(this.zip.files);
for (const path of files) {
const file = this.zip.file(path);
if (file && !file.dir) {
const blob = await file.async('blob');
resources.set(path, blob);
}
}
return resources;
}
private async extractCover(): Promise<Blob | undefined> {
if (!this.packageDoc) return undefined;
// Try cover-id metadata
const coverId = this.packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content');
if (coverId) {
const coverItem = this.packageDoc.querySelector(`manifest [id="${coverId}"]`);
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
if (coverHref && this.zip) {
const coverFile = this.zip.file(coverHref);
if (coverFile) {
return coverFile.async('blob');
}
}
}
}
// Fallback: look for cover image in manifest
const coverItem = this.packageDoc.querySelector('manifest item[properties~="cover-image"]');
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
if (coverHref && this.zip) {
const coverFile = this.zip.file(coverHref);
if (coverFile) {
return coverFile.async('blob');
}
}
}
return undefined;
}
private resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseDir + relativePath;
}
// Get a specific spine item as HTML document
async getSpineItem(spinItem: EPUBSpineItem): Promise<HTMLDocument> {
if (!this.zip) throw new Error('EPUB not loaded');
const content = await this.getFileContent(spinItem.href);
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
// Inject base URL for resolving relative links
const base = doc.createElement('base');
base.href = spinItem.href;
doc.head.insertBefore(base, doc.head.firstChild);
return doc;
}
}
5.2 HTML Renderer
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
}
class HTMLRenderer {
private container: HTMLElement;
private config: RendererConfig;
private loadedFonts: Set<string> = new Set();
constructor(container: HTMLElement, config: RendererConfig) {
this.container = container;
this.config = config;
}
async renderDocument(doc: HTMLDocument): Promise<void> {
// Apply theme
this.applyTheme();
// Apply typography settings
this.applyTypography();
// Inject custom styles for reader
this.injectReaderStyles();
// Handle embedded fonts
await this.loadEmbeddedFonts(doc);
// Handle images
this.processImages(doc);
// Clear container and append content
this.container.innerHTML = '';
this.container.appendChild(doc.body);
// Apply column layout
this.applyColumnLayout();
}
private applyTheme(): void {
// IMPORTANT: Hybrid theming approach
//
// UI Chrome (reader shell, bars, panels): All 11 Bookhoard themes
// Ebook text content: Reading-optimized themes only (5 options)
// Comics/manga: All 11 Bookhoard themes (visual content works well with colors)
//
// Why? Long-form reading requires eye-comfort optimization.
// Popular ebook readers (Kindle, Kobo, Apple Books) offer 3-5 reading themes,
// not unlimited colors. Purple text for 300 pages causes eye fatigue.
// Reading-optimized themes (ebooks only)
const readingThemes: Record<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[this.config.readingTheme] || readingThemes['dark'];
for (const [key, value] of Object.entries(theme)) {
this.container.style.setProperty(key, value);
}
}
private applyTypography(): void {
const style = document.createElement('style');
// Get font stack for selected reading font
const fontStack = getFontStack(this.config.readingFont);
style.textContent = `
.ebook-content {
font-family: ${fontStack};
font-size: ${this.config.fontSize}px;
line-height: ${this.config.lineHeight};
text-align: ${this.config.textAlign};
padding: 0 ${this.config.marginWidth}px;
max-width: 100%;
overflow-wrap: break-word;
}
.ebook-content p {
margin-bottom: 1em;
text-indent: ${this.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);
}
`;
this.container.appendChild(style);
}
private injectReaderStyles(): void {
// Add ARIA roles for accessibility
this.container.setAttribute('role', 'main');
this.container.setAttribute('aria-label', 'Book content');
}
private async loadEmbeddedFonts(doc: HTMLDocument): Promise<void> {
// Find @font-face rules in document
const styleSheets = doc.querySelectorAll('style');
for (const sheet of styleSheets) {
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
for (const match of matches) {
const fontFace = match[1];
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
if (urlMatch) {
const fontUrl = urlMatch[1];
await this.loadFont(fontUrl);
}
}
}
}
private async loadFont(fontUrl: string): Promise<void> {
if (this.loadedFonts.has(fontUrl)) return;
try {
const fontFace = new FontFace('custom-font', `url(${fontUrl})`);
await fontFace.load();
document.fonts.add(fontFace);
this.loadedFonts.add(fontUrl);
} catch (error) {
console.error('Failed to load font:', fontUrl, error);
}
}
private processImages(doc: HTMLDocument): void {
const images = doc.querySelectorAll('img');
images.forEach((img) => {
// Add loading="lazy" for performance
img.setAttribute('loading', 'lazy');
// Add alt text if missing
if (!img.alt) {
img.alt = 'Image from book';
}
// Make images clickable for full-screen view
img.style.cursor = 'pointer';
img.addEventListener('click', () => {
this.showImageFullscreen(img.src);
});
});
}
private showImageFullscreen(src: string): void {
// Create modal for full-screen image viewing
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50';
modal.onclick = () => modal.remove();
const img = document.createElement('img');
img.src = src;
img.className = 'max-w-full max-h-full object-contain';
modal.appendChild(img);
document.body.appendChild(modal);
}
private applyColumnLayout(): void {
if (this.config.columnCount === 2) {
this.container.style.columnCount = '2';
this.container.style.columnGap = `${this.config.marginWidth}px`;
this.container.style.columnRule = '1px solid var(--text-secondary)';
} else {
this.container.style.columnCount = 'auto';
}
}
updateConfig(newConfig: Partial<RendererConfig>): void {
this.config = { ...this.config, ...newConfig };
// Re-render with new config
const currentDoc = this.container.querySelector('.ebook-content')?.ownerDocument;
if (currentDoc) {
this.renderDocument(currentDoc as HTMLDocument);
}
}
}
5.3 CFI Navigation
File: web/src/reader/ebook/cfi-navigator.ts
// EPUB CFI (Canonical Fragment Identifier) navigation
// Reuses logic from internal/sync/format.go
// CFI format: /6/4[chap1ref]!/4[body01]/10/2/1:3[2]
interface CFIComponent {
type: 'index' | 'indirection-step' | 'text-location';
value: number;
id?: string;
textOffset?: number;
}
class CFINavigator {
// Parse CFI string into components
static parseCFI(cfi: string): CFIComponent[] {
const components: CFIComponent[] = [];
// Remove leading ! if present
const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi;
// Split by /
const parts = cleanCFI.split('/').filter(Boolean);
for (const part of parts) {
// Parse component
const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/);
if (match) {
const component: CFIComponent = {
type: match[3] !== undefined ? 'text-location' : 'index',
value: parseInt(match[1], 10),
id: match[2]
};
if (match[3]) {
component.textOffset = parseInt(match[3], 10);
}
components.push(component);
}
}
return components;
}
// Generate CFI for a specific text node
static generateCFI(
spineIndex: number,
elementPath: number[],
textOffset: number = 0,
spineItemId?: string
): string {
let cfi = `/6/${spineIndex}`;
if (spineItemId) {
cfi += `[${spineItemId}]`;
}
for (const index of elementPath) {
cfi += `/${index}`;
}
if (textOffset > 0) {
cfi += `:${textOffset}`;
}
return cfi;
}
// Navigate to CFI in document
static navigateToCFI(doc: Document, cfi: string): Element | Text | null {
const components = this.parseCFI(cfi);
if (components.length === 0) return null;
// First component is spine index (handled by caller)
// Remaining components navigate through DOM
let current: Node | null = doc.body;
for (let i = 1; i < components.length; i++) {
const component = components[i];
if (component.type === 'index') {
// Navigate to child index
if (current instanceof Element) {
const children = this.getElementChildren(current);
current = children[component.value] || null;
}
}
}
return current as Element | Text;
}
// Get CFI for current selection
static getSelectionCFI(doc: Document): string | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
// Build path to start container
const path: number[] = [];
let current: Node | null = startContainer;
while (current && current !== doc.body) {
const parent = current.parentElement;
if (parent) {
const siblings = this.getElementChildren(parent);
const index = siblings.indexOf(current as Element);
path.unshift(index);
}
current = parent;
}
// Generate CFI
const spineIndex = 0; // This would come from current spine item
const textOffset = range.startOffset;
return this.generateCFI(spineIndex, path, textOffset);
}
// Get percentage from CFI
static getPercentageFromCFI(cfi: string): number {
// Simplified: use text location as percentage
const components = this.parseCFI(cfi);
const textLocation = components.find(c => c.type === 'text-location');
if (textLocation && textLocation.textOffset !== undefined) {
// Assume 1000 characters per "page" for rough calculation
return Math.min((textLocation.textOffset / 10), 100);
}
return 0;
}
private static getElementChildren(element: Element): Element[] {
return Array.from(element.children).filter(el =>
el.nodeType === Node.ELEMENT_NODE
) as Element[];
}
// Compare two CFIs to determine reading order
static compareCFIs(cfi1: string, cfi2: string): number {
const components1 = this.parseCFI(cfi1);
const components2 = this.parseCFI(cfi2);
const maxLen = Math.max(components1.length, components2.length);
for (let i = 0; i < maxLen; i++) {
const comp1 = components1[i];
const comp2 = components2[i];
if (!comp1) return -1;
if (!comp2) return 1;
if (comp1.value !== comp2.value) {
return comp1.value - comp2.value;
}
}
return 0;
}
}
5.4 Libre Reading Fonts (Bundled)
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.4.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.4.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.4.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.4.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>
...
}
5.4.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.4.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.4.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.5 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';
}
class TypographyEngine {
private container: HTMLElement;
private config: TypographyConfig;
constructor(container: HTMLElement, config: TypographyConfig) {
this.container = container;
this.config = config;
}
apply(): void {
const content = this.container.querySelector('.ebook-content');
if (!content) return;
// Get font stack for selected reading font
const fontStack = getFontStack(this.config.readingFont);
// Apply base styles
content.setAttribute('style', `
font-family: ${fontStack};
font-size: ${this.config.fontSize}px;
line-height: ${this.config.lineHeight};
text-align: ${this.config.textAlign};
margin-top: ${this.config.marginTop}px;
margin-bottom: ${this.config.marginBottom}px;
margin-left: ${this.config.marginLeft}px;
margin-right: ${this.config.marginRight}px;
text-indent: ${this.config.textIndent}px;
-webkit-font-smoothing: ${this.config.fontSmoothing};
-moz-osx-font-smoothing: ${this.config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'};
`);
// Apply hyphenation if enabled
if (this.config.hyphenate) {
this.enableHyphenation(content as HTMLElement);
}
// Enable/disable ligatures
this.setLigatures(content as HTMLElement, this.config.ligatures);
// Apply justification settings
if (this.config.textAlign === 'justify') {
this.enableJustification(content as HTMLElement);
}
}
private enableHyphenation(element: HTMLElement): void {
// Use CSS hyphens with lang attribute
element.style.hyphens = 'auto';
element.style.hyphenateLimitChars = '6 3 3'; // min 6, before 3, after 3
// Add language attribute from EPUB metadata
const lang = this.container.closest('[data-language]')?.getAttribute('data-language') || 'en';
element.setAttribute('lang', lang);
}
private setLigatures(element: HTMLElement, enabled: boolean): void {
if (enabled) {
element.style.fontVariantLigatures = 'common-ligatures';
element.style.fontFeatureSettings = '"liga", "dlig"';
} else {
element.style.fontVariantLigatures = 'no-common-ligatures';
element.style.fontFeatureSettings = 'normal';
}
}
private enableJustification(element: HTMLElement): void {
// Add proper word breaking for justified text
element.style.wordBreak = 'normal';
element.style.overflowWrap = 'break-word';
element.style.wordWrap = 'break-word';
// Adjust letter spacing for better appearance
element.style.letterSpacing = '0.01em';
}
updateConfig(newConfig: Partial<TypographyConfig>): void {
this.config = { ...this.config, ...newConfig };
this.apply();
}
// Measure reading time for current content
measureReadingTime(wordsPerMinute: number = 250): number {
const content = this.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);
}
// Get word count for current content
getWordCount(): number {
const content = this.container.querySelector('.ebook-content');
if (!content) return 0;
const text = content.textContent || '';
return text.split(/\s+/).length;
}
}
5.5 Search Within Ebook
File: web/src/reader/ebook/search.ts
// Search within ebook content
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
class EbookSearcher {
private epubPackage: EPUBPackage;
constructor(epubPackage: EPUBPackage) {
this.epubPackage = epubPackage;
}
async search(query: string): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
// Search all spine items
for (const [index, spineItem] of this.epubPackage.spine.entries()) {
const doc = await this.getSpineItemDocument(spineItem);
if (!doc) continue;
// Get chapter title
const chapterTitle = this.getChapterTitle(spineItem);
// Search in text nodes
const textNodes = this.findTextNodes(doc.body);
for (const node of textNodes) {
const text = node.textContent || '';
const lowerText = text.toLowerCase();
let foundAt = 0;
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
// Generate CFI for this match
const cfi = this.generateCFIForNode(node, foundAt);
// Extract snippet with context
const snippet = this.extractSnippet(text, foundAt, query.length);
results.push({
cfi,
snippet,
chapterTitle
});
foundAt += lowerQuery.length;
}
}
}
return results;
}
private async getSpineItemDocument(spineItem: EPUBSpineItem): Promise<Document | null> {
try {
const content = await this.epubPackage.resources.get(spineItem.href)?.text();
if (!content) return null;
const parser = new DOMParser();
return parser.parseFromString(content, 'text/html');
} catch (error) {
console.error('Failed to load spine item:', spineItem.href, error);
return null;
}
}
private findTextNodes(root: Node): Text[] {
const textNodes: Text[] = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip script, style, and empty text nodes
const parent = node.parentElement;
if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent?.trim()) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
return textNodes;
}
private generateCFIForNode(node: Text, offset: number): string {
// Build path to node
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const parent = current.parentNode;
const siblings = Array.from(parent.childNodes)
.filter(n => n.nodeType === Node.ELEMENT_NODE);
const index = siblings.indexOf(current as Node);
path.unshift(index);
current = parent;
}
// Get spine index (simplified - would need proper tracking)
const spineIndex = 0;
return CFINavigator.generateCFI(spineIndex, path, offset);
}
private extractSnippet(text: string, offset: number, length: number): string {
const contextBefore = 30;
const contextAfter = 50;
const start = Math.max(0, offset - contextBefore);
const end = Math.min(text.length, offset + length + contextAfter);
let snippet = text.substring(start, end);
// Add ellipsis if truncated
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
return snippet;
}
private getChapterTitle(spineItem: EPUBSpineItem): string {
// Find TOC entry for this spine item
for (const toc of this.epubPackage.toc) {
if (toc.href === spineItem.href) {
return toc.label;
}
// Check children
for (const child of toc.children) {
if (child.href === spineItem.href) {
return child.label;
}
}
}
return 'Chapter ' + (this.epubPackage.spine.indexOf(spineItem) + 1);
}
}
5.6 Copy Text Handler
File: web/src/reader/ebook/copy-handler.ts
// Handle text copying with citation
class CopyHandler {
private currentMediaItem: MediaItemSummary;
constructor(mediaItem: MediaItemSummary) {
this.currentMediaItem = mediaItem;
}
async copySelection(): Promise<boolean> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const selectedText = selection.toString();
if (!selectedText.trim()) return false;
// Create citation
const citation = this.createCitation(selectedText);
try {
await navigator.clipboard.writeText(citation);
// Show toast
showToast('Copied to clipboard', 'success');
return true;
} catch (error) {
console.error('Failed to copy:', error);
showToast('Failed to copy to clipboard', 'error');
return false;
}
}
private createCitation(text: string): string {
const citation = `"${text}"\n`;
citation += `— ${this.currentMediaItem.title}`;
if (this.currentMediaItem.author) {
citation += ` by ${this.currentMediaItem.author}`;
}
citation += `\n(Source: Bookhoard)`;
return citation;
}
// Enable copy on right-click
enableContextMenuCopy(): void {
document.addEventListener('contextmenu', async (e) => {
const selection = window.getSelection();
const selectedText = selection?.toString().trim();
if (selectedText) {
e.preventDefault();
await this.copySelection();
}
});
}
}
5.7 View Modes
File: web/src/reader/ebook/view-modes.ts
// Different viewing modes for ebooks
type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';
class ViewModeManager {
private container: HTMLElement;
private currentMode: ViewMode = 'paginated';
constructor(container: HTMLElement) {
this.container = container;
}
setViewMode(mode: ViewMode): void {
this.currentMode = mode;
this.applyMode();
}
private applyMode(): void {
const content = this.container.querySelector('.ebook-content');
if (!content) return;
// Reset all modes
content.classList.remove(
'paginated',
'scrolled',
'single-column',
'double-column'
);
// Apply current mode
switch (this.currentMode) {
case 'paginated':
this.applyPaginatedMode(content as HTMLElement);
break;
case 'scrolled':
this.applyScrolledMode(content as HTMLElement);
break;
case 'single-column':
this.applySingleColumn(content as HTMLElement);
break;
case 'double-column':
this.applyDoubleColumn(content as HTMLElement);
break;
}
}
private applyPaginatedMode(element: HTMLElement): void {
element.classList.add('paginated');
element.style.height = '100vh';
element.style.overflow = 'hidden';
element.style.columnCount = '1';
element.style.columnGap = '0';
// Enable page-by-page navigation
this.enablePagination(element);
}
private applyScrolledMode(element: HTMLElement): void {
element.classList.add('scrolled');
element.style.height = 'auto';
element.style.overflowY = 'auto';
element.style.columnCount = '1';
// Disable pagination
this.disablePagination(element);
}
private applySingleColumn(element: HTMLElement): void {
element.classList.add('single-column');
element.style.columnCount = '1';
element.style.columnGap = '0';
element.style.maxWidth = '800px';
element.style.margin = '0 auto';
}
private applyDoubleColumn(element: HTMLElement): void {
element.classList.add('double-column');
element.style.columnCount = '2';
element.style.columnGap = '60px';
element.style.columnRule = '1px solid var(--text-secondary)';
element.style.maxWidth = '1400px';
element.style.margin = '0 auto';
}
private enablePagination(element: HTMLElement): void {
// Calculate pages based on content height
const totalHeight = element.scrollHeight;
const pageHeight = element.clientHeight;
const pageCount = Math.ceil(totalHeight / pageHeight);
// Add pagination controls
this.addPaginationControls(pageCount);
}
private disablePagination(element: HTMLElement): void {
// Remove pagination controls
const controls = this.container.querySelector('.pagination-controls');
controls?.remove();
}
private addPaginationControls(pageCount: number): void {
let currentPage = 1;
// Create controls UI
const controls = document.createElement('div');
controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t';
controls.innerHTML = `
<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>
`;
// Add event listeners
controls.querySelector('.prev-page')?.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
this.goToPage(currentPage);
}
});
controls.querySelector('.next-page')?.addEventListener('click', () => {
if (currentPage < pageCount) {
currentPage++;
this.goToPage(currentPage);
}
});
this.container.appendChild(controls);
}
private goToPage(pageNumber: number): void {
const content = this.container.querySelector('.ebook-content') as HTMLElement;
if (!content) return;
const pageHeight = content.clientHeight;
const scrollTop = (pageNumber - 1) * pageHeight;
content.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
// Update page info
const pageInfo = this.container.querySelector('.page-info');
if (pageInfo) {
pageInfo.textContent = `Page ${pageNumber} of ${this.getTotalPageCount()}`;
}
}
private getTotalPageCount(): number {
const content = this.container.querySelector('.ebook-content') as HTMLElement;
if (!content) return 1;
const totalHeight = content.scrollHeight;
const pageHeight = content.clientHeight;
return Math.ceil(totalHeight / pageHeight);
}
}
6. Panel Detection Implementation
5.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;
}
5.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
}));
}
5.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);
}
6. Lazy Loading & Caching
6.1 Page Cache (5-Page Ahead)
File: web/src/reader/comic/page-cache.ts
// Lazy-loading page cache with 5-page ahead prefetch
class PageCache {
private cache: Map<number, HTMLImageElement> = new Map();
private loading: Set<number> = new Set();
private maxAhead = 5;
constructor(private mediaItemId: string) {}
async getPage(pageNumber: number): Promise<HTMLImageElement> {
// Check cache first
if (this.cache.has(pageNumber)) {
return this.cache.get(pageNumber)!;
}
// Check if already loading
if (this.loading.has(pageNumber)) {
// Wait for existing load
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.cache.has(pageNumber)) {
clearInterval(checkInterval);
resolve(this.cache.get(pageNumber)!);
}
}, 100);
});
}
// Load page
this.loading.add(pageNumber);
const img = await this.loadPage(pageNumber);
this.cache.set(pageNumber, img);
this.loading.delete(pageNumber);
// Prefetch next pages
this.prefetch(pageNumber + 1);
// Clean up old pages (keep last 10)
this.cleanup(pageNumber);
return img;
}
private async loadPage(pageNumber: number): Promise<HTMLImageElement> {
const token = localStorage.getItem('token');
const response = await fetch(
`/api/readers/${this.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;
}
private prefetch(startPage: number): void {
for (let i = startPage; i < startPage + this.maxAhead; i++) {
if (!this.cache.has(i) && !this.loading.has(i)) {
// Start loading in background
this.loadPage(i).then((img) => {
this.cache.set(i, img);
});
}
}
}
private cleanup(currentPage: number): void {
const keepPages = 10;
for (const [page,] of this.cache) {
if (page < currentPage - keepPages) {
this.cache.delete(page);
}
}
}
}
7. Offline Support (PWA)
7.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);
}
})
);
})
);
});
7.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
}
7.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');
});
8. Dictionary Implementation
8.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.
8.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 });
}
}
});
}
9. Reading Statistics Integration
9.1 Track Reading Speed
File: web/src/reader/reading-speed-tracker.ts
// Track reading speed and update database
class ReadingSpeedTracker {
private startTime: number | null = null;
private pagesRead = 0;
private wordsRead = 0;
private lastSync = Date.now();
constructor(private mediaItemId: string) {}
startReadingSession(): void {
this.startTime = Date.now();
this.pagesRead = 0;
this.wordsRead = 0;
}
recordPageTurn(): void {
if (!this.startTime) return;
this.pagesRead++;
// Sync every 5 pages or every 5 minutes
const now = Date.now();
if (this.pagesRead % 5 === 0 || (now - this.lastSync) > 5 * 60 * 1000) {
this.sync();
this.lastSync = now;
}
}
recordWordsRead(wordCount: number): void {
this.wordsRead += wordCount;
}
private async sync(): void {
if (!this.startTime) return;
const minutesElapsed = (Date.now() - this.startTime) / (1000 * 60);
const pagesPerMinute = this.pagesRead / minutesElapsed;
const wordsPerMinute = this.wordsRead / minutesElapsed;
await apiPut(`/readers/${this.mediaItemId}/reading-speed`, {
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: this.pagesRead,
total_reading_minutes: minutesElapsed
});
}
}
10. UI/UX Implementation
10.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>
}
11. Integration Tests
11.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"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
func TestReaderEndpoints(t *testing.T) {
setup := setupTestServer(t)
defer teardownTestServer(t, setup)
// Create test user and media item
ctx := setup.ctx
queries := setup.queries
user := createTestUser(t, ctx, queries)
admin := createTestAdmin(t, ctx, queries)
mediaItem := createTestMediaItem(t, ctx, queries, user.ID)
accessToken := loginTestUser(t, setup, user.Email, "password123")
adminToken := loginTestUser(t, setup, admin.Email, "admin123")
t.Run("Get Reader Page - User", func(t *testing.T) {
// Test SSR reader page
// Test that user can access their own media items
})
t.Run("Get Reader Page - No User", func(t *testing.T) {
// Test 401 without authentication
})
t.Run("Get Page - Lazy Loading", func(t *testing.T) {
// Test page lazy loading endpoint
})
t.Run("Get Chapters", func(t *testing.T) {
// Test chapter metadata endpoint
})
t.Run("Get Panels - Grid Detection", func(t *testing.T) {
// Test panel detection endpoint
})
t.Run("Update Panels - Manual Override", func(t *testing.T) {
// Test manual panel override (admin only)
})
t.Run("Reading Speed", func(t *testing.T) {
// Test reading speed tracking
})
t.Run("Dictionary Lookup", func(t *testing.T) {
// Test dictionary endpoint
})
t.Run("Settings Management", func(t *testing.T) {
// Test settings CRUD
})
t.Run("Offline Support - Service Worker", func(t *testing.T) {
// Test service worker registration
// Test offline caching
})
}
12. 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
13. Code Reuse Strategy
13.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
13.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
}
14. Bruno API Tests
File: bruno/reader/reader.bru (new folder)
Create Bruno OpenCollection YAML requests for:
bruno/reader/
├── get-reader-page.bru
├── get-page.bru
├── get-chapters.bru
├── get-panels.bru
├── update-panels.bru
├── get-reading-speed.bru
├── update-reading-speed.bru
├── lookup-word.bru
├── get-settings.bru
└── update-settings.bru
Follow existing Bruno patterns from bruno/media/ and bruno/auth/.
15. Documentation
15.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
15.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
16. Success Criteria
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
Performance Requirements
- ⚡ Initial page load: < 2 seconds
- ⚡ Page turn (comics): < 500ms with 5-page cache
- ⚡ Panel zoom animation: 300ms smooth
- ⚡ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached)
- ⚡ Offline cache hit: < 100ms
Quality Requirements
- ✅ Zero TypeScript errors
- ✅ All integration tests passing
- ✅ Zero known security vulnerabilities
- ✅ Mobile-responsive (320px - 4K)
- ✅ Keyboard accessible
- ✅ WCAG 2.1 AA compliant
17. 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 hybrid architecture balances code reuse with medium-specific optimization, while the phased approach allows for incremental development and testing.
Key principles:
- Reuse existing systems (WebSocket sync, progress tracking, annotations)
- Surgical code edits (extend, don't duplicate)
- Follow existing patterns (service layer, test patterns)
- Progressive enhancement (SSR-first, JavaScript enhancements)
- Privacy-first (per-user settings, localStorage fallback)
- Offline-capable (PWA with service worker)
- Libre fonts only (8 bundled open-source reading fonts)
- Hybrid theming (11 themes for UI/comics, 5 reading-optimized themes for ebook text)
Key design decisions:
- Fonts: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only (400, 400i, 700, 700i)
- Theming: Hybrid approach - all 11 Bookhoard themes for UI/chrome, 5 reading-optimized themes for ebook text
- Typography: Optimized for extended reading (Literata default, designed for Google Play Books)
- UI Elements: Share fonts with rest of Bookhoard app (not reading fonts)
Estimated timeline: 8 weeks for full implementation
Next steps:
- Review and approve this plan
- Begin Phase 1: Infrastructure & Basic Reader
- Create database schema
- Implement service layer and handlers
- Build reader template and frontend infrastructure
Plan created: 2025 Last updated: 2025