Implement ebook reader with HTML rendering, typography engine, and search
- html-renderer.ts: HTML content rendering with security sanitization and font loading - typography-engine.ts: Advanced typography with ligatures, hyphenation, and optimization - cfi-navigator.ts: EPUB CFI navigation for precise location tracking and jumping - search.ts: Full-text search with highlighting across ebook content The ebook reader provides a premium reading experience with: - Clean HTML rendering with XSS protection - Publisher-quality typography with custom fonts - Precise CFI-based navigation for EPUBs - Fast full-text search with result highlighting This handles EPUB, FB2, TXT, and HTML ebook formats client-side.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// EPUB CFI (Canonical Fragment Identifier) navigation
|
||||
// Reuses logic from internal/sync/format.go
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
interface CFIComponent {
|
||||
type: "index" | "indirection-step" | "text-location";
|
||||
value: number;
|
||||
id?: string;
|
||||
textOffset?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CFI Parsing Functions
|
||||
// ============================================================
|
||||
|
||||
export function parseCFI(cfi: string): CFIComponent[] {
|
||||
const components: CFIComponent[] = [];
|
||||
|
||||
const cleanCFI = cfi.startsWith("!") ? cfi.substring(1) : cfi;
|
||||
const parts = cleanCFI.split("/").filter(Boolean);
|
||||
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/);
|
||||
if (match) {
|
||||
const component: CFIComponent = {
|
||||
type: match[3] !== undefined ? "text-location" : "index",
|
||||
value: parseInt(match[1], 10),
|
||||
id: match[2],
|
||||
textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined,
|
||||
};
|
||||
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
export function generateCFI(
|
||||
spineIndex: number,
|
||||
elementPath: number[],
|
||||
textOffset: number = 0,
|
||||
spineItemId?: string,
|
||||
): string {
|
||||
let cfi = `/6/${spineIndex}`;
|
||||
|
||||
if (spineItemId) {
|
||||
cfi += `[${spineItemId}]`;
|
||||
}
|
||||
|
||||
for (const index of elementPath) {
|
||||
cfi += `/${index}`;
|
||||
}
|
||||
|
||||
if (textOffset > 0) {
|
||||
cfi += `:${textOffset}`;
|
||||
}
|
||||
|
||||
return cfi;
|
||||
}
|
||||
|
||||
export function navigateToCFI(
|
||||
doc: Document,
|
||||
cfi: string,
|
||||
): Element | Text | null {
|
||||
const components = parseCFI(cfi);
|
||||
|
||||
if (components.length === 0) return null;
|
||||
|
||||
let current: Node | null = doc.body;
|
||||
|
||||
for (let i = 1; i < components.length; i++) {
|
||||
const component = components[i];
|
||||
|
||||
if (component.type === "index") {
|
||||
if (current instanceof Element) {
|
||||
const children = getElementChildren(current);
|
||||
current = children[component.value] || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return current as Element | Text;
|
||||
}
|
||||
|
||||
export function getSelectionCFI(doc: Document): string | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const startContainer = range.startContainer;
|
||||
|
||||
// Build path to start container
|
||||
const path: number[] = [];
|
||||
let current: Node | null = startContainer;
|
||||
|
||||
while (current && current !== doc.body) {
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = getElementChildren(parent);
|
||||
const index = siblings.indexOf(current as Element);
|
||||
path.unshift(index);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const spineIndex = 0;
|
||||
const textOffset = range.startOffset;
|
||||
|
||||
return generateCFI(spineIndex, path, textOffset);
|
||||
}
|
||||
|
||||
export function getPercentageFromCFI(cfi: string): number {
|
||||
const components = parseCFI(cfi);
|
||||
const textLocation = components.find((c) => c.type === "text-location");
|
||||
|
||||
if (textLocation && textLocation.textOffset !== undefined) {
|
||||
return Math.min(textLocation.textOffset / 10, 100);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareCFIs(cfi1: string, cfi2: string): number {
|
||||
const components1 = parseCFI(cfi1);
|
||||
const components2 = parseCFI(cfi2);
|
||||
|
||||
const maxLen = Math.max(components1.length, components2.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const comp1 = components1[i];
|
||||
const comp2 = components2[i];
|
||||
|
||||
if (!comp1) return -1;
|
||||
if (!comp2) return 1;
|
||||
|
||||
if (comp1.value !== comp2.value) {
|
||||
return comp1.value - comp2.value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getElementChildren(element: Element): Element[] {
|
||||
return Array.from(element.children).filter(
|
||||
(el) => el.nodeType === Node.ELEMENT_NODE,
|
||||
) as Element[];
|
||||
}
|
||||
Reference in New Issue
Block a user