Files
bookhoard/web/src/reader/ebook/html-renderer.ts
T
john-okeefe 1e47b0e459 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.
2026-04-03 22:29:25 -04:00

249 lines
6.3 KiB
TypeScript

// HTML rendering with theme support, font loading, and image handling
// Procedural style: Functions, not classes
interface RendererConfig {
readingTheme: "light" | "sepia" | "dark" | "night" | "high-contrast";
readingFont:
| "literata"
| "crimson"
| "source-serif"
| "eb-garamond"
| "libertinus"
| "noto-serif"
| "charis-sil"
| "ibm-plex";
fontSize: number;
lineHeight: number;
marginWidth: number;
textAlign: "left" | "justify";
columnCount: 1 | 2;
}
// ============================================================
// Main Render Function
// ============================================================
export async function renderHTMLDocument(
doc: HTMLDocument,
container: HTMLElement,
config: RendererConfig,
): Promise<void> {
// Apply theme
applyHTMLTheme(container, config.readingTheme);
// Apply typography settings
applyHTMLTypography(container, config);
// Inject custom styles for reader
injectHTMLReaderStyles(container);
// Handle embedded fonts
await loadEmbeddedHTMLFonts(doc, container);
// Handle images
processHTMLImages(doc, container);
// Clear container and append content
container.innerHTML = "";
container.appendChild(doc.body);
// Apply column layout
applyHTMLColumnLayout(container, config.columnCount);
}
// ============================================================
// Theme Application
// ============================================================
function applyHTMLTheme(container: HTMLElement, theme: string): void {
const readingThemes: Record<string, Record<string, string>> = {
light: {
"--bg-primary": "#ffffff",
"--text-primary": "#1a1a1a",
"--text-secondary": "#666666",
"--accent": "#0066cc",
},
sepia: {
"--bg-primary": "#f4ecd8",
"--text-primary": "#5f4b32",
"--text-secondary": "#8b7355",
"--accent": "#8b4513",
},
dark: {
"--bg-primary": "#1a1b26",
"--text-primary": "#c0caf5",
"--text-secondary": "#565f89",
"--accent": "#7aa2f7",
},
night: {
"--bg-primary": "#0d1117",
"--text-primary": "#c9d1d9",
"--text-secondary": "#8b949e",
"--accent": "#58a6ff",
},
"high-contrast": {
"--bg-primary": "#000000",
"--text-primary": "#ffffff",
"--text-secondary": "#cccccc",
"--accent": "#ffff00",
},
};
const themeConfig = readingThemes[theme] || readingThemes["dark"];
for (const [key, value] of Object.entries(themeConfig)) {
container.style.setProperty(key, value);
}
}
function applyHTMLTypography(
container: HTMLElement,
config: RendererConfig,
): void {
const style = document.createElement("style");
const fontStack = getFontStack(config.readingFont);
style.textContent = `
.ebook-content {
font-family: ${fontStack};
font-size: ${config.fontSize}px;
line-height: ${config.lineHeight};
text-align: ${config.textAlign};
padding: 0 ${config.marginWidth}px;
max-width: 100%;
overflow-wrap: break-word;
}
.ebook-content p {
margin-bottom: 1em;
text-indent: ${config.textAlign === "justify" ? "1.5em" : "0"};
}
.ebook-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
.ebook-content a {
color: var(--accent);
text-decoration: underline;
}
.ebook-content a:active {
color: var(--text-secondary);
}
`;
container.appendChild(style);
}
function injectHTMLReaderStyles(container: HTMLElement): void {
container.setAttribute("role", "main");
container.setAttribute("aria-label", "Book content");
}
async function loadEmbeddedHTMLFonts(
doc: HTMLDocument,
container: HTMLElement,
): Promise<void> {
const styleSheets = doc.querySelectorAll("style");
for (const sheet of styleSheets) {
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
for (const match of matches) {
const fontFace = match[1];
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
if (urlMatch) {
const fontUrl = urlMatch[1];
await loadHTMLFont(fontUrl, container);
}
}
}
}
async function loadHTMLFont(
fontUrl: string,
container: HTMLElement,
): Promise<void> {
const loadedFonts = container.dataset.loadedFonts
? JSON.parse(container.dataset.loadedFonts)
: [];
if (loadedFonts.includes(fontUrl)) return;
try {
const fontFace = new FontFace("custom-font", `url(${fontUrl})`);
await fontFace.load();
document.fonts.add(fontFace);
loadedFonts.push(fontUrl);
container.dataset.loadedFonts = JSON.stringify(loadedFonts);
} catch (error) {
console.error("Failed to load font:", fontUrl, error);
}
}
function processHTMLImages(doc: HTMLDocument): void {
const images = doc.querySelectorAll("img");
images.forEach((img) => {
img.setAttribute("loading", "lazy");
if (!img.alt) {
img.alt = "Image from book";
}
img.style.cursor = "pointer";
img.addEventListener("click", () => {
showImageFullscreen(img.src);
});
});
}
function showImageFullscreen(src: string): void {
const modal = document.createElement("div");
modal.className =
"fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50";
modal.onclick = () => modal.remove();
const img = document.createElement("img");
img.src = src;
img.className = "max-w-full max-h-full object-contain";
modal.appendChild(img);
document.body.appendChild(modal);
}
function applyHTMLColumnLayout(
container: HTMLElement,
columnCount: number,
): void {
if (columnCount === 2) {
container.style.columnCount = "2";
container.style.columnGap = "20px";
container.style.columnRule = "1px solid var(--text-secondary)";
} else {
container.style.columnCount = "auto";
}
}
function getFontStack(font: string): string {
const stacks: Record<string, string> = {
literata: '"Literata", serif',
crimson: '"Crimson Text", serif',
"source-serif": '"Source Serif 4", serif',
"eb-garamond": '"EB Garamond", serif',
libertinus: '"Libertinus Serif", serif',
"noto-serif": '"Noto Serif", serif',
"charis-sil": '"Charis SIL", serif',
"ibm-plex": '"IBM Plex Serif", serif',
};
return stacks[font] || stacks["literata"];
}