Remove duplicate and incomplete sections from reader implementation plan

Removed 3 duplicate/incomplete sections:
- Section 5.8 (incomplete typography-engine.ts without font support)
- Section 5.12 (duplicate search.ts - kept version in section 5.9)
- Section 5.10.4 (incomplete reader.templ template - kept complete version in section 12.1)

The plan now contains only complete, implementation-ready sections:
- Complete typography-engine.ts with getFontStack() and all 8 bundled fonts
- Complete search.ts matching actual implementation with export functions
- Complete reader.templ SSR template with Alpine.js integration

This resolves confusion about which version to follow and ensures the plan
matches the actual codebase implementation.
This commit is contained in:
2026-04-03 23:13:06 -04:00
parent a387efad22
commit 104a6a1a3d
+1 -437
View File
@@ -4606,95 +4606,6 @@ function getElementChildren(element: Element): Element[] {
---
### 5.8 Typography Engine (Procedural)
**File:** `web/src/reader/ebook/typography-engine.ts`
```typescript
// Typography engine for ebook rendering
// Procedural style: Functions, not classes
interface TypographyConfig {
fontSize: number;
lineHeight: number;
textAlign: 'left' | 'justify';
hyphenate: boolean;
ligatures: boolean;
fontSmoothing: 'auto' | 'grayscale';
}
export function applyTypographyConfig(
element: HTMLElement,
config: TypographyConfig
): void {
// Enable/disable ligatures
setLigatures(element, config.ligatures);
// Enable/disable hyphenation
if (config.hyphenate) {
enableHyphenation(element);
}
// Apply justification settings
if (config.textAlign === 'justify') {
enableJustification(element);
}
// Apply font smoothing
element.style.fontSmooth = config.fontSmoothing;
}
function setLigatures(element: HTMLElement, enabled: boolean): void {
if (enabled) {
element.style.fontVariantLigatures = 'common-ligatures';
element.style.fontFeatureSettings = '"liga", "dlig"';
} else {
element.style.fontVariantLigatures = 'no-common-ligatures';
element.style.fontFeatureSettings = 'normal';
}
}
function enableHyphenation(element: HTMLElement): void {
element.style.hyphens = 'auto';
element.style.hyphenateLimitChars = '6 3 3';
// Add language attribute from EPUB metadata
const lang = element.closest('[data-language]')?.getAttribute('data-language') || 'en';
element.setAttribute('lang', lang);
}
function enableJustification(element: HTMLElement): void {
element.style.wordBreak = 'normal';
element.style.overflowWrap = 'break-word';
element.style.wordWrap = 'break-word';
element.style.letterSpacing = '0.01em';
}
export function measureReadingTime(
container: HTMLElement,
wordsPerMinute: number = 250
): number {
const content = container.querySelector('.ebook-content');
if (!content) return 0;
const text = content.textContent || '';
const words = text.split(/\s+/).length;
const minutes = words / wordsPerMinute;
return Math.ceil(minutes);
}
export function getWordCount(container: HTMLElement): number {
const content = container.querySelector('.ebook-content');
if (!content) return 0;
const text = content.textContent || '';
return text.split(/\s+/).length;
}
```
---
### 5.9 Ebook Search (Procedural)
**File:** `web/src/reader/ebook/search.ts`
@@ -5103,194 +5014,7 @@ All fonts use SIL Open Font License 1.1
WOFF2 format, ~1.2MB total"
```
#### 5.10.4 Font Loading in Templates
**File:** `templates/reader.templ` (updated)
Add to `<head>` section:
```go
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="bg-white dark:bg-gray-900" data-media-item-id={ metadata.id }>
<!-- Reader Chrome (Navigation Bars) -->
<div id="reader-chrome" class="reader-chrome bg-gray-100 dark:bg-gray-800 border-b border-gray-300 dark:border-gray-700">
<!-- Top Navigation Bar -->
<header class="flex items-center justify-between px-4 py-2">
<div class="flex items-center space-x-4">
<button
hx-get="/library"
hx-push-url="true"
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
← Back to Library
</button>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
{ metadata.title }
</h1>
</div>
<div class="flex items-center space-x-4">
<!-- Progress Indicator -->
<div id="progress-indicator" class="text-sm text-gray-600 dark:text-gray-400">
<span id="current-position">--</span> / <span id="total-position">--</span>
</div>
<!-- Settings Button -->
<button
x-data="{ open: false }"
@click="open = !open"
class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
⚙️ Settings
</button>
</div>
</header>
<!-- Table of Contents Panel (Slide-in) -->
<div id="toc-panel" class="hidden fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-900 shadow-lg z-50">
<div class="p-4">
<h2 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Table of Contents</h2>
<nav id="toc-list" class="space-y-2">
<!-- TOC items will be populated by JavaScript -->
</nav>
</div>
</div>
</div>
<!-- Reader Content Area -->
<main id="reader-content" class="flex-1 overflow-auto">
<!-- Content will be rendered here by the appropriate reader -->
</main>
<!-- Bottom Navigation Bar -->
<footer class="fixed bottom-0 left-0 right-0 bg-gray-100 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 p-4">
<div class="flex items-center justify-between max-w-4xl mx-auto">
<button
id="prev-button"
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
← Previous
</button>
<div class="flex-1 mx-4">
<input
type="range"
id="position-slider"
min="0"
max="100"
value="0"
class="w-full"
/>
</div>
<button
id="next-button"
class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next →
</button>
</div>
</footer>
<!-- Settings Panel (Slide-in) -->
<div id="settings-panel" class="hidden fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-900 shadow-lg z-50 overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Reader Settings</h2>
<button id="close-settings" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
</button>
</div>
<!-- Font Settings -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font</h3>
<select id="font-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="literata">Literata (Default)</option>
<option value="crimson">Crimson Text</option>
<option value="source-serif">Source Serif 4</option>
<option value="eb-garamond">EB Garamond</option>
<option value="libertinus">Libertinus Serif</option>
<option value="noto-serif">Noto Serif</option>
<option value="charis-sil">Charis SIL</option>
<option value="ibm-plex">IBM Plex Serif</option>
</select>
</div>
<!-- Font Size -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Font Size: <span id="font-size-value">16px</span></h3>
<input
type="range"
id="font-size-slider"
min="12"
max="24"
value="16"
class="w-full"
/>
</div>
<!-- Theme -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reading Theme</h3>
<div class="grid grid-cols-2 gap-2">
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="light">
☀️ Light
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="sepia">
📜 Sepia
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="dark">
🌙 Dark
</button>
<button class="theme-btn px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" data-theme="night">
⭐ Night
</button>
</div>
</div>
<!-- Line Height -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Line Spacing</h3>
<select id="line-height-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="1.4">Compact</option>
<option value="1.6" selected>Normal</option>
<option value="1.8">Relaxed</option>
<option value="2.0">Loose</option>
</select>
</div>
<!-- Margins -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Page Margins</h3>
<select id="margin-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800">
<option value="narrow">Narrow</option>
<option value="medium" selected>Medium</option>
<option value="wide">Wide</option>
</select>
</div>
</div>
</div>
<!-- JavaScript Bundle -->
<script src="/static/reader.js"></script>
</body>
</html>
}
```
#### 5.10.5 Alternative: Use Google Fonts CDN (Not Recommended)
#### 5.10.4 Alternative: Use Google Fonts CDN (Not Recommended)
If you don't want to bundle fonts (slower initial load, privacy concerns):
@@ -5820,166 +5544,6 @@ function getWordCount(container: HTMLElement): number {
}
```
### 5.12 Search Within Ebook
**File:** `web/src/reader/ebook/search.ts`
```typescript
// Search within ebook content
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
// Search within ebook content
// Procedural implementation (no OOP)
interface SearchResult {
cfi: string;
snippet: string;
chapterTitle: string;
}
async function searchEbook(
epubPackage: EPUBPackage,
query: string
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
for (const [index, spineItem] of epubPackage.spine.entries()) {
const doc = await getSpineItemDocument(epubPackage, spineItem);
if (!doc) continue;
const chapterTitle = getChapterTitle(epubPackage, spineItem);
const textNodes = findTextNodes(doc.body);
for (const node of textNodes) {
const text = node.textContent || '';
const lowerText = text.toLowerCase();
let foundAt = 0;
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
const cfi = generateSearchCFI(node, foundAt);
const snippet = extractSearchSnippet(text, foundAt, query.length);
results.push({
cfi,
snippet,
chapterTitle
});
foundAt += lowerQuery.length;
}
}
}
return results;
}
async function getSpineItemDocument(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem
): Promise<Document | null> {
try {
const content = await epubPackage.resources.get(spineItem.href)?.text();
if (!content) return null;
const parser = new DOMParser();
return parser.parseFromString(content, 'text/html');
} catch (error) {
console.error('Failed to load spine item:', spineItem.href, error);
return null;
}
}
function findTextNodes(root: Node): Text[] {
const textNodes: Text[] = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const parent = node.parentElement;
if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent?.trim()) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
return textNodes;
}
function generateSearchCFI(node: Text, offset: number): string {
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const parent = current.parentNode;
const siblings = Array.from(parent.childNodes)
.filter(n => n.nodeType === Node.ELEMENT_NODE);
const index = siblings.indexOf(current as Node);
path.unshift(index);
current = parent;
}
const spineIndex = 0;
return generateCFI(spineIndex, path, offset);
}
function extractSearchSnippet(text: string, offset: number, length: number): string {
const contextBefore = 30;
const contextAfter = 50;
const start = Math.max(0, offset - contextBefore);
const end = Math.min(text.length, offset + length + contextAfter);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
return snippet;
}
function getChapterTitle(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem
): string {
for (const toc of epubPackage.toc) {
if (toc.href === spineItem.href) {
return toc.label;
}
for (const child of toc.children) {
if (child.href === spineItem.href) {
return child.label;
}
}
}
return 'Chapter ' + (epubPackage.spine.indexOf(spineItem) + 1);
}
```
### 5.13 Copy Text Handler
**File:** `web/src/reader/ebook/copy-handler.ts`