// Import types import { UniversalReader } from "../../reader-shell"; import type { SpineItem, SpineInfo, PageBoundary, PaginationData, PaginationSettings, ReadingPosition, } from "./types"; interface TextNodeInfo { node: Text; startChar: number; // Character position in HTML endChar: number; // Character position in HTML textStart: number; // Word position in plain text textEnd: number; // Word position in plain text } function buildTextNodeMapping(html: string): TextNodeInfo[] { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const textNodes: TextNodeInfo[] = []; let htmlCharPos = 0; let textWordPos = 0; function traverse(node: Node) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ""; const words = countWords(text); textNodes.push({ node: node as Text, startChar: htmlCharPos, endChar: htmlCharPos + text.length, textStart: textWordPos, textEnd: textWordPos + words, }); textWordPos += words; htmlCharPos += text.length; } else { // For element nodes, just count the opening tag length if (node.nodeType === Node.ELEMENT_NODE) { const outerHTML = (node as Element).outerHTML; const tagEnd = outerHTML.indexOf(">") + 1; htmlCharPos += tagEnd; // Recurse into children node.childNodes.forEach(traverse); // Count closing tag const tagName = (node as Element).tagName; htmlCharPos += ``.length; } } } doc.body.childNodes.forEach(traverse); return textNodes; } function mapWordToHtmlChar(mapping: TextNodeInfo[], wordPos: number): number { for (const info of mapping) { if (wordPos >= info.textStart && wordPos <= info.textEnd) { // Word is in this text node const ratio = (wordPos - info.textStart) / (info.textEnd - info.textStart); return Math.round( info.startChar + ratio * (info.endChar - info.startChar), ); } } return 0; // Fallback } // Constants for word count estimation (from Kavita) const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height // Calculate words per page based on settings function calculateWordsPerPage(settings: PaginationSettings): number { const fontSizeFactor = 16 / settings.fontSize; const lineHeightFactor = 1.6 / settings.lineHeight; const areaFactor = (settings.viewportWidth * settings.viewportHeight) / (800 * 600); return Math.round( WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor, ); } // Extract plain text from HTML function extractTextFromHTML(html: string): string { // Remove script and style tags const withoutScripts = html.replace( /)<[^<]*)*<\/script>/gi, "", ); const withoutStyles = withoutScripts.replace( /)<[^<]*)*<\/style>/gi, "", ); // Extract text content (simple version, no DOM) return withoutStyles .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") .trim(); } // Count words in text function countWords(text: string): number { return text .trim() .split(/\s+/) .filter((w) => w.length > 0).length; } // Split text into word ranges for pages function splitIntoWordRanges( wordCount: number, wordsPerPage: number, ): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; let start = 0; while (start < wordCount) { const end = Math.min(start + wordsPerPage, wordCount); ranges.push({ start, end }); start = end; } return ranges; } // Escape special characters in CFI function escapeCFIString(str: string): string { return str .replace(/\[/g, "\\[") .replace(/\]/g, "\\]") .replace(/\(/g, "\\(") .replace(/\)/g, "\\)") .replace(/,/g, "\\,") .replace(/;/g, "\\;") .replace(/=/g, "\\="); } // Generate EPUB CFI for a position in spine // Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/ // Format: epubcfi(/6/spine_index!/path/element/offset) function generateCFI( spineIndex: number, charOffset: number, totalChars: number, spineItemId: string, ): string { const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : ""; const offset = Math.min(charOffset, totalChars); const spinePath = `/6/${spineIndex + 2}${escapedId}`; return `epubcfi(${spinePath}!/4/2/1:${offset})`; } // Parse EPUB CFI to extract position function parseCFI( cfi: string, ): { spineIndex: number; charOffset: number } | null { if (!cfi.startsWith("epubcfi(")) { return null; } // Remove epubcfi( wrapper const inner = cfi.slice(8, -1); if (!inner) return null; // Split on ! to separate spine path from content path const parts = inner.split("!"); if (parts.length < 2) return null; // Extract spine index from /6/4 or /6/4[id] format const spineMatch = parts[0].match(/\/6\/(\d+)/); if (!spineMatch) return null; const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset if (spineIndex < 0) return null; // Extract character offset from :123 format const offsetMatch = parts[1].match(/:(\d+)$/); if (!offsetMatch) return null; const charOffset = parseInt(offsetMatch[1]); return { spineIndex, charOffset }; } // Calculate pagination for entire book export async function calculatePagination( spineItems: SpineItem[], contentMap: Map, settings: PaginationSettings, ): Promise { const wordsPerPage = calculateWordsPerPage(settings); const spines: SpineInfo[] = []; const pageMap = new Map(); let globalPageIndex = 0; // Process each spine item for (let i = 0; i < spineItems.length; i++) { const spineItem = spineItems[i]; // Skip non-HTML items (cover pages, etc) if (spineItem.type !== "html") { spines.push({ spineIndex: i, spineItemId: spineItem.id, content: "", charCount: 0, wordCount: 0, cfiStart: "", pages: [], }); continue; } // Get content const contentBlob = contentMap.get(spineItem.content); if (!contentBlob) { console.warn(`Content not found for spine ${spineItem.id}`); continue; } const contentHTML = await contentBlob.text(); const plainText = extractTextFromHTML(contentHTML); const wordCount = countWords(plainText); const charCount = plainText.length; // Skip empty spines if (wordCount === 0) { spines.push({ spineIndex: i, spineItemId: spineItem.id, content: contentHTML, charCount, wordCount, cfiStart: generateCFI(i, 0, charCount, spineItem.id), pages: [], }); continue; } // Split into pages based on TEXT content, not HTML const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage); const pages: PageBoundary[] = []; // Build a map of word positions to HTML character positions const textNodeInfo = buildTextNodeMapping(contentHTML); for (let j = 0; j < wordRanges.length; j++) { const range = wordRanges[j]; // Map word positions to ACTUAL HTML character positions const htmlCharStart = mapWordToHtmlChar(textNodeInfo, range.start); const htmlCharEnd = mapWordToHtmlChar(textNodeInfo, range.end); const page: PageBoundary = { pageIndex: globalPageIndex, localPageIndex: j, charStart: htmlCharStart, charEnd: htmlCharEnd, wordStart: range.start, wordEnd: range.end, cfi: generateCFI(i, htmlCharStart, charCount, spineItem.id), }; pages.push(page); pageMap.set(globalPageIndex, page); globalPageIndex++; } spines.push({ spineIndex: i, spineItemId: spineItem.id, content: contentHTML, charCount, wordCount, cfiStart: generateCFI(i, 0, charCount, spineItem.id), pages, }); } // Build map const spineMap = new Map(); for (const spine of spines) { spineMap.set(spine.spineIndex, spine); } return { totalPages: globalPageIndex, spines, spineMap, pageMap, calculatedAt: Date.now(), settings: { ...settings, wordsPerPage }, }; } // Find which page contains a CFI export function findPageByCFI( pagination: PaginationData, targetCFI: string, ): number { const parsed = parseCFI(targetCFI); if (!parsed) return 1; const { spineIndex, charOffset } = parsed; const spine = pagination.spineMap.get(spineIndex); if (!spine || spine.pages.length === 0) return 1; // Find page containing this character offset for (const page of spine.pages) { if (charOffset >= page.charStart && charOffset < page.charEnd) { return page.pageIndex + 1; // 1-indexed } } return 1; } // Extract HTML slice between character offsets function extractHTMLSlice( html: string, charStart: number, charEnd: number, ): string { if (charStart === 0 && charEnd >= html.length) { return html; } // Parse HTML and extract text nodes within the character range const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const body = doc.body; // Find all text nodes and their cumulative character counts type TextNodeInfo = { node: Text; startChar: number; endChar: number }; const textNodes: TextNodeInfo[] = []; let cumulativeChars = 0; function traverse(node: Node) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ""; const startChar = cumulativeChars; cumulativeChars += text.length; const endChar = cumulativeChars; textNodes.push({ node: node as Text, startChar, endChar }); } else if (node.nodeType === Node.ELEMENT_NODE) { // Skip script and style tags if (node instanceof HTMLElement) { const tagName = node.tagName.toLowerCase(); if (tagName === "script" || tagName === "style") { return; } } // Recursively traverse children for (const child of Array.from(node.childNodes)) { traverse(child); } } } traverse(body); // Find which text nodes intersect with the requested range const relevantNodes: { node: Text; before: string; after: string }[] = []; for (const { node, startChar, endChar } of textNodes) { if (endChar <= charStart || startChar >= charEnd) { // No overlap continue; } const text = node.textContent || ""; let resultText = text; // Trim from left if node starts before page if (startChar < charStart) { resultText = text.substring(charStart - startChar); } // Trim from right if node extends past page end if (endChar > charEnd) { // Calculate where to cut within the (potentially already trimmed) text const cutPosition = charEnd - startChar; resultText = text.substring(0, cutPosition); } // Handle case where both trims are needed if (startChar < charStart && endChar > charEnd) { const leftTrim = charStart - startChar; const rightTrim = endChar - charEnd; resultText = text.substring(leftTrim, text.length - rightTrim); } relevantNodes.push({ node, before: "", after: resultText }); } // Preserve original HTML structure for nodes in range const startNode = textNodes.find((n) => n.endChar > charStart); const endNode = textNodes.find((n) => n.startChar < charEnd); if (!startNode || !endNode) { return html; } // Find element boundaries let startElement: Node | null = startNode.node; while (startElement && startElement.parentNode !== body) { startElement = startElement.parentNode; } let endElement: Node | null = endNode.node; while (endElement && endElement.parentNode !== body) { endElement = endElement.parentNode; } // Extract and modify the relevant portion if (startElement && endElement) { const fragment = document.createDocumentFragment(); let currentElement: Node | null = startElement; let foundEnd = false; while (currentElement && !foundEnd) { if (currentElement.nodeType === Node.ELEMENT_NODE) { const clone = (currentElement as Element).cloneNode(false); fragment.appendChild(clone); // Process children for (const child of Array.from(currentElement.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { const textNodeInfo = textNodes.find((n) => n.node === child); if (textNodeInfo) { const modified = document.createTextNode( relevantNodes.find((n) => n.node === child)?.after || "", ); clone.appendChild(modified); } } else if (child.nodeType === Node.ELEMENT_NODE) { // Recursively handle element children const childClone = child.cloneNode(true); clone.appendChild(childClone); } } if (currentElement === endElement) { foundEnd = true; } } currentElement = currentElement.nextSibling; } // Serialize fragment back to HTML const tempDiv = document.createElement("div"); tempDiv.appendChild(fragment); return tempDiv.innerHTML; } // Fallback: return original HTML if extraction fails return html; } // Get page content (HTML slice for a page) export function getPageContent( pagination: PaginationData, pageIndex: number, ): string { const page = pagination.pageMap.get(pageIndex); if (!page) return ""; // Find the spine that contains this page // Pages are stored in order, so we can find the spine by checking which pages it contains let spine: SpineInfo | undefined; for (const s of pagination.spines) { if (s.pages.some((p) => p.pageIndex === pageIndex)) { spine = s; break; } } if (!spine) return ""; // Extract HTML content between page boundaries const htmlSlice = extractHTMLSlice( spine.content, page.charStart, page.charEnd, ); // Wrap in a div to ensure valid HTML structure return `
${htmlSlice}
`; } // Recalculate pagination on viewport change export function shouldRecalculate( pagination: PaginationData | null, newSettings: PaginationSettings, ): boolean { if (!pagination) return true; const sizeChanged = Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) > 50 || Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) > 50; const fontChanged = pagination.settings.fontSize !== newSettings.fontSize; const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight; return sizeChanged || fontChanged || lineChanged; } // Create position object from page number export function createPositionFromPage( book: UniversalReader, page: number, ): ReadingPosition { if (!book.pagination) { return { currentPage: 1, spineIndex: 0, localPageIndex: 0, cfi: "", progress: 0, }; } const pageIndex = page - 1; const pageData = book.pagination.pageMap.get(pageIndex); if (!pageData) { return { currentPage: 1, spineIndex: 0, localPageIndex: 0, cfi: "", progress: 0, }; } // Find which spine this page belongs to let spineIndex = 0; for (const spine of book.pagination.spines) { if (pageData.localPageIndex < spine.pages.length) { spineIndex = spine.spineIndex; break; } } return { currentPage: page, spineIndex, localPageIndex: pageData.localPageIndex, cfi: pageData.cfi, progress: book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0, }; }