Files
bookhoard/EPUB_PAGE_CALCULATOR.md
T
john-okeefe 26c0a2d001 feat: add viewport-based dynamic page calculation for EPUBs
- Create page-calculator.ts module with viewport-based pagination
- Implement calculatePagesForEbook to measure rendered content
- Add getCurrentPageFromScroll for scroll position to page mapping
- Add getScrollPositionForPage for page to scroll position mapping
- Include calculateProgressPercentage for progress tracking
- Create documentation with full implementation spec
2026-04-05 21:14:38 -04:00

15 KiB

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

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<number, ChapterPageInfo>;
  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(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
  result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
  result = result.replace(/<link[^>]*>/gi, "");
  return result;
}

async function renderContentForMeasurement(
  content: string,
  resources: Map<string, Blob>,
  config: HiddenContainerConfig
): Promise<HTMLElement> {
  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<PageCalculationResult> {
  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<number, ChapterPageInfo>();
  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:

import type { PageCalculationResult } from "../ebook/page-calculator";

Add to the reader state interfaces:

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:

import {
  calculatePagesForEbook,
  getCurrentPageFromScroll,
  getScrollPositionForPage,
  calculateProgressPercentage,
  type PageCalculationResult,
} from "../ebook/page-calculator";

Add state variable after getState() declaration:

let pageCalculationResult: PageCalculationResult | null = null;
let isCalculatingPages = false;

Add function to initialize page calculation (call after reader is ready):

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:

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:

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:

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:

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:

readerEvents.emit("readerReady", currentReader);

// ADD THIS: Initialize page calculation
setTimeout(() => {
  const { initializePageCalculation } = require("./core/reader-navigation");
  initializePageCalculation();
}, 100);

Or if using dynamic import:

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:

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

// 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