# EPUB Dynamic Page Calculator - Phase 2 Implementation This document contains the complete implementation for dynamic viewport-based EPUB page calculation. ## File Structure ``` web/src/reader/ebook/page-calculator.ts (NEW) web/src/reader/core/reader-state.ts (MODIFY) web/src/reader/core/reader-navigation.ts (MODIFY) web/src/reader/features/progress-indicator.ts (MODIFY) web/src/reader/reader-shell.ts (MODIFY) ``` --- ## 1. New File: page-calculator.ts Create: `web/src/reader/ebook/page-calculator.ts` ```typescript import type { EbookCIF, SpineItem } from "../../types/reader"; export interface ChapterPageInfo { spineIndex: number; spineItemId: string; content: string; startPage: number; endPage: number; scrollHeight: number; charCount: number; } export interface PageCalculationResult { totalPages: number; chapters: ChapterPageInfo[]; chapterMap: Map; calculatedAt: number; settings: PageCalculationSettings; } export interface PageCalculationSettings { fontSize: number; lineHeight: number; marginWidth: number; viewportHeight: number; } interface HiddenContainerConfig { width: number; fontSize: number; lineHeight: number; paddingTop: number; paddingBottom: number; paddingLeft: number; paddingRight: number; } function getDefaultContainerConfig(viewportWidth: number, settings: PageCalculationSettings): HiddenContainerConfig { const contentWidth = viewportWidth - (settings.marginWidth * 2); return { width: contentWidth, fontSize: settings.fontSize, lineHeight: settings.lineHeight, paddingTop: settings.marginWidth, paddingBottom: settings.marginWidth, paddingLeft: settings.marginWidth, paddingRight: settings.marginWidth, }; } function createHiddenContainer(config: HiddenContainerConfig): HTMLElement { const container = document.createElement("div"); container.id = "page-calculation-hidden"; container.style.position = "absolute"; container.style.left = "-9999px"; container.style.top = "0"; container.style.width = `${config.width}px`; container.style.fontSize = `${config.fontSize}px`; container.style.lineHeight = config.lineHeight.toString(); container.style.padding = `${config.paddingTop}px ${config.paddingRight}px ${config.paddingBottom}px ${config.paddingLeft}px`; container.style.boxSizing = "border-box"; container.style.overflow = "hidden"; container.style.wordWrap = "break-word"; container.style.whiteSpace = "pre-wrap"; return container; } function stripScriptsAndStyles(html: string): string { let result = html; result = result.replace(/]*>[\s\S]*?<\/script>/gi, ""); result = result.replace(/]*>[\s\S]*?<\/style>/gi, ""); result = result.replace(/]*>/gi, ""); return result; } async function renderContentForMeasurement( content: string, resources: Map, config: HiddenContainerConfig ): Promise { const container = createHiddenContainer(config); const parser = new DOMParser(); const doc = parser.parseFromString(stripScriptsAndStyles(content), "text/html"); const images = doc.querySelectorAll("img"); for (const img of images) { const src = img.getAttribute("src"); if (!src) continue; let blob = resources.get(src); if (!blob) blob = resources.get(src.split("/").pop() || ""); if (blob) { const blobUrl = URL.createObjectURL(blob); img.setAttribute("src", blobUrl); } } container.appendChild(doc.body); document.body.appendChild(container); return container; } function calculatePageBreaks(scrollHeight: number, viewportHeight: number): number { if (scrollHeight <= 0 || viewportHeight <= 0) return 1; return Math.ceil(scrollHeight / viewportHeight); } export async function calculatePagesForEbook( cif: EbookCIF, viewportWidth: number, settings: { fontSize?: number; lineHeight?: number; marginWidth?: number; } ): Promise { const fontSize = settings.fontSize || 16; const lineHeight = settings.lineHeight || 1.6; const marginWidth = settings.marginWidth || 20; const viewportHeight = window.innerHeight - 120; const pageSettings: PageCalculationSettings = { fontSize, lineHeight, marginWidth, viewportHeight, }; const config = getDefaultContainerConfig(viewportWidth, pageSettings); const chapters: ChapterPageInfo[] = []; let currentPage = 1; for (let i = 0; i < cif.spine.length; i++) { const spineItem = cif.spine[i]; if (spineItem.type !== "html") { chapters.push({ spineIndex: i, spineItemId: spineItem.id, content: "", startPage: currentPage, endPage: currentPage, scrollHeight: 0, charCount: 0, }); continue; } const contentBlob = cif.resources.get(spineItem.content); if (!contentBlob) { chapters.push({ spineIndex: i, spineItemId: spineItem.id, content: "", startPage: currentPage, endPage: currentPage, scrollHeight: 0, charCount: 0, }); continue; } const contentText = await contentBlob.text(); const charCount = contentText.replace(/<[^>]*>/g, "").length; let scrollHeight = 0; let pagesInChapter = 1; try { const container = await renderContentForMeasurement( contentText, cif.resources, config ); scrollHeight = container.scrollHeight; pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight); container.remove(); } catch (error) { console.warn("Failed to measure content for spine item:", spineItem.id, error); pagesInChapter = Math.max(1, Math.ceil(charCount / 1500)); } const chapterInfo: ChapterPageInfo = { spineIndex: i, spineItemId: spineItem.id, content: contentText, startPage: currentPage, endPage: currentPage + pagesInChapter - 1, scrollHeight, charCount, }; chapters.push(chapterInfo); currentPage += pagesInChapter; } const chapterMap = new Map(); for (const chapter of chapters) { chapterMap.set(chapter.spineIndex, chapter); } const totalPages = currentPage - 1; return { totalPages, chapters, chapterMap, calculatedAt: Date.now(), settings: pageSettings, }; } export function getCurrentPageFromScroll( pageInfo: PageCalculationResult, currentSpineIndex: number, scrollPosition: number, viewportHeight: number ): number { const chapter = pageInfo.chapterMap.get(currentSpineIndex); if (!chapter || chapter.pagesInChapter === 0) { return 1; } const viewportHeightAdjusted = viewportHeight - 120; const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted); return Math.min(chapter.endPage, Math.max(chapter.startPage, chapter.startPage + positionInChapter)); } export function getScrollPositionForPage( pageInfo: PageCalculationResult, targetPage: number, viewportHeight: number ): { spineIndex: number; scrollTop: number } | null { const viewportHeightAdjusted = viewportHeight - 120; for (const chapter of pageInfo.chapters) { if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) { const positionInChapter = targetPage - chapter.startPage; const scrollTop = positionInChapter * viewportHeightAdjusted; return { spineIndex: chapter.spineIndex, scrollTop, }; } } return null; } export function calculateProgressPercentage( pageInfo: PageCalculationResult, currentPage: number ): number { if (pageInfo.totalPages <= 0) return 0; return Math.round((currentPage / pageInfo.totalPages) * 100); } ``` --- ## 2. Modify: reader-state.ts Update the state to include page calculation results. Find the `CurrentReader` type or `EbookReader` interface and add: ```typescript import type { PageCalculationResult } from "../ebook/page-calculator"; ``` Add to the reader state interfaces: ```typescript interface UniversalReader { type: "ebook"; cif: any; currentSpineIndex: number; pageCalculationResult?: PageCalculationResult; // ADD THIS currentScrollPosition?: number; // ADD THIS } ``` --- ## 3. Modify: reader-navigation.ts Add scroll tracking and integrate page calculator. ### Add imports at top of file: ```typescript import { calculatePagesForEbook, getCurrentPageFromScroll, getScrollPositionForPage, calculateProgressPercentage, type PageCalculationResult, } from "../ebook/page-calculator"; ``` ### Add state variable after getState() declaration: ```typescript let pageCalculationResult: PageCalculationResult | null = null; let isCalculatingPages = false; ``` ### Add function to initialize page calculation (call after reader is ready): ```typescript async function initializePageCalculation() { const state = getState(); if (!state.currentReader || state.currentReader.type !== "ebook") return; if (isCalculatingPages) return; isCalculatingPages = true; try { const settings = getDefaultSettings(); const viewportWidth = window.innerWidth; pageCalculationResult = await calculatePagesForEbook( state.currentReader.cif, viewportWidth, { fontSize: settings.font_size, lineHeight: settings.line_height, marginWidth: settings.margin_width, } ); state.currentReader.pageCalculationResult = pageCalculationResult; setState({ currentReader: state.currentReader }); console.log("Page calculation complete:", pageCalculationResult.totalPages, "pages"); } catch (error) { console.error("Failed to calculate pages:", error); } finally { isCalculatingPages = false; } } ``` ### Update renderSpineItem function to reset scroll position when changing chapters: After `state.currentReader.currentSpineIndex++` or `--`, add: ```typescript if (pageCalculationResult) { const chapter = pageCalculationResult.chapterMap.get(state.currentReader.currentSpineIndex); if (chapter) { // Reset scroll to start of new chapter container.scrollTop = 0; state.currentReader.currentScrollPosition = 0; } } ``` ### Add scroll tracking setup in renderSpineItem: After `container.innerHTML = ...`, add: ```typescript container.addEventListener("scroll", () => { const state = getState(); if (!state.currentReader || state.currentReader.type !== "ebook") return; state.currentReader.currentScrollPosition = container.scrollTop; if (pageCalculationResult) { const viewportHeight = window.innerHeight; const currentPage = getCurrentPageFromScroll( pageCalculationResult, state.currentReader.currentSpineIndex, container.scrollTop, viewportHeight ); const percentage = calculateProgressPercentage(pageCalculationResult, currentPage); readerEvents.emit("progressUpdated", { currentPage, totalPages: pageCalculationResult.totalPages, percentage, }); } }, { passive: true }); ``` ### Update navigation functions to use page calculation: In `nextPage()` and `previousPage()`, after updating state: ```typescript if (pageCalculationResult) { const container = document.getElementById("reader-content"); if (container) { const viewportHeight = window.innerHeight; const target = getScrollPositionForPage( pageCalculationResult, state.currentReader.currentPage || state.currentReader.currentSpineIndex + 1, viewportHeight ); if (target && target.spineIndex !== state.currentReader.currentSpineIndex) { state.currentReader.currentSpineIndex = target.spineIndex; container.scrollTop = target.scrollTop; } } } ``` --- ## 4. Modify: progress-indicator.ts Update to use dynamic page calculation. Find the ebook handling in `updateProgressDisplay()` function and replace: ```typescript if (state.currentReader.type === "ebook") { const reader = state.currentReader as any; const pageInfo = reader.pageCalculationResult; if (pageInfo && pageInfo.totalPages > 0) { const container = document.getElementById("reader-content"); const viewportHeight = window.innerHeight; const currentPage = getCurrentPageFromScroll( pageInfo, reader.currentSpineIndex, container?.scrollTop || 0, viewportHeight ); currentPage = currentPage; totalPages = pageInfo.totalPages; } else { // Fallback to estimated pages while calculating currentPage = reader.currentSpineIndex + 1; totalPages = state.readerMetadata?.total_pages || state.currentReader.cif.locations?.estimatedPages || state.currentReader.cif.spine.length; } } ``` --- ## 5. Modify: reader-shell.ts Initialize page calculation after reader is ready. Find where `renderSpineItem()` is called after `readerEvents.emit("readerReady", ...)`, add: ```typescript readerEvents.emit("readerReady", currentReader); // ADD THIS: Initialize page calculation setTimeout(() => { const { initializePageCalculation } = require("./core/reader-navigation"); initializePageCalculation(); }, 100); ``` Or if using dynamic import: ```typescript readerEvents.emit("readerReady", currentReader); // ADD THIS: Initialize page calculation after a short delay setTimeout(async () => { const { initializePageCalculation } = await import("./core/reader-navigation"); initializePageCalculation(); }, 100); ``` --- ## 6. Handle Settings Changes When reading settings change (font size, margins), recalculate pages. In reader-navigation.ts, add listener in `renderSpineItem` or setup function: ```typescript context?.events.on("settings:changed", async (detail: { settings: any }) => { const state = getState(); if (!state.currentReader || state.currentReader.type !== "ebook") return; console.log("Recalculating pages due to settings change..."); const viewportWidth = window.innerWidth; pageCalculationResult = await calculatePagesForEbook( state.currentReader.cif, viewportWidth, { fontSize: detail.settings.font_size, lineHeight: detail.settings.line_height, marginWidth: detail.settings.margin_width, } ); state.currentReader.pageCalculationResult = pageCalculationResult; setState({ currentReader: state.currentReader }); }); ``` --- ## Testing Checklist 1. Load any EPUB - should see page count in progress indicator 2. Scroll through content - page number should update in real-time 3. Click next/previous chapter - should jump to correct page number 4. Change font size in settings - pages should recalculate 5. Change margins in settings - pages should recalculate 6. Resize browser window - pages should recalculate 7. Navigate between different EPUB files - should work correctly each time ## Console Debug Commands ```javascript // Check page calculation state window.pageInfo // Force recalculation import("./core/reader-navigation").then(m => m.initializePageCalculation()) // Check current page document.getElementById("reader-content").scrollTop ``` --- ## Notes - The page calculation is asynchronous and may take a moment for large books - A loading state can be shown while calculating - The calculation uses a hidden container that is immediately removed after measurement - Images are converted to blob URLs for measurement to get accurate heights - The viewport height is reduced by 120px to account for top/bottom chrome bars