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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// 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"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Search within ebook content
|
||||||
|
// Procedural style: Functions, not classes
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
cfi: string;
|
||||||
|
snippet: string;
|
||||||
|
chapterTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EbookSearchConfig {
|
||||||
|
epubPackage: EPUBPackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Search Function
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function searchEbook(
|
||||||
|
epubPackage: EPUBPackage,
|
||||||
|
query: string,
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Search all spine items
|
||||||
|
for (const [index, spineItem] of epubPackage.spine.entries()) {
|
||||||
|
const doc = await getSpineItemDocument(epubPackage, spineItem);
|
||||||
|
|
||||||
|
if (!doc) continue;
|
||||||
|
|
||||||
|
const chapterTitle = getChapterTitle(spineItem);
|
||||||
|
|
||||||
|
// Search in text nodes
|
||||||
|
const textNodes = findTextNodes(doc.body);
|
||||||
|
|
||||||
|
for (const node of textNodes) {
|
||||||
|
const text = node.textContent || "";
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
|
||||||
|
let foundAt = 0;
|
||||||
|
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
||||||
|
const cfi = generateCFIForNode(node, foundAt);
|
||||||
|
const snippet = extractSnippet(text, foundAt, query.length);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
cfi,
|
||||||
|
snippet,
|
||||||
|
chapterTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
foundAt += lowerQuery.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSpineItemDocument(
|
||||||
|
epubPackage: EPUBPackage,
|
||||||
|
spineItem: EPUBSpineItem,
|
||||||
|
): Promise<Document | null> {
|
||||||
|
try {
|
||||||
|
const content = await epubPackage.resources.get(spineItem.href)?.text();
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
return parser.parseFromString(content, "text/html");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load spine item:", spineItem.href, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChapterTitle(spineItem: EPUBSpineItem): string {
|
||||||
|
// Extract title from spine item or use default
|
||||||
|
return spineItem.id || `Section ${spineItem.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTextNodes(root: Node): Text[] {
|
||||||
|
const textNodes: Text[] = [];
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: (node) => {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.textContent?.trim()) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
textNodes.push(node as Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return textNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCFIForNode(node: Text, offset: number): string {
|
||||||
|
const path: number[] = [];
|
||||||
|
let current: Node | null = node;
|
||||||
|
|
||||||
|
while (current && current.parentNode) {
|
||||||
|
const parent = current.parentNode;
|
||||||
|
const siblings = Array.from(parent.childNodes).filter(
|
||||||
|
(n) => n.nodeType === Node.ELEMENT_NODE,
|
||||||
|
);
|
||||||
|
const index = siblings.indexOf(current as Node);
|
||||||
|
|
||||||
|
path.unshift(index);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spineIndex = 0; // Would come from parent context
|
||||||
|
|
||||||
|
return generateCFI(spineIndex, path, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSnippet(text: string, offset: number, length: number): string {
|
||||||
|
const contextBefore = 30;
|
||||||
|
const contextAfter = 50;
|
||||||
|
|
||||||
|
const start = Math.max(0, offset - contextBefore);
|
||||||
|
const end = Math.min(text.length, offset + length + contextAfter);
|
||||||
|
|
||||||
|
return text.slice(start, end);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user