diff --git a/EPUB_PAGE_CALCULATOR.md b/EPUB_PAGE_CALCULATOR.md new file mode 100644 index 0000000..4b774d5 --- /dev/null +++ b/EPUB_PAGE_CALCULATOR.md @@ -0,0 +1,569 @@ +# 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 \ No newline at end of file diff --git a/web/src/reader/ebook/page-calculator.ts b/web/src/reader/ebook/page-calculator.ts new file mode 100644 index 0000000..73a13c7 --- /dev/null +++ b/web/src/reader/ebook/page-calculator.ts @@ -0,0 +1,280 @@ +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 = Array.from(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); +}