feat(ebook-reader): implement page-based pagination with CFI support

This commit implements true page-based pagination for the ebook reader,
similar to Kindle's approach where content is split into discrete pages
based on viewport size, font settings, and content layout.

Phase 1 - Bug Fixes:
- Fix goToPage to use getScrollPositionForPage instead of treating
  page numbers as spine indices
- Move page calculation to initialize before first render to avoid
  race condition with scroll handler
- Add fallback page calculation when pageCalculationResult is null

Phase 2 - Page Splitting:
- Add new page-splitter.ts module with CFI-based splitting for EPUB
  and height-based fallback for other formats
- Integrate splitContent into page-calculator to generate discrete
  page content for each spine item
- Store pages[] array in ChapterPageInfo for rendering
- Rewrite navigation (nextPage, previousPage, renderSpineItem) to
  use page-based approach with currentPage tracking
- Remove old scroll-based pagination from view-modes.ts
- Update paginated mode CSS for true page clipping

Phase 3 - Progress Display:
- Update progress-indicator to use currentPage directly instead of
  calculating from scroll position
- Fix getCurrentPage in reader-state to return currentPage for ebooks

Phase 4 - Interface Fixes:
- Add currentPage field to UniversalReader interface
- Update sendProgressUpdate to use currentPage directly
- Initialize currentPage to 1 on reader creation

Key features:
- Dynamic page count based on font size, line height, margins
- CFI-based page splitting preserves reading context
- Falls back to height-based splitting for non-EPUB formats
- Progress display updates immediately on page change
- Settings changes trigger page recalculation and re-render
This commit is contained in:
2026-04-06 21:18:29 -04:00
parent 6805d0b66d
commit 1e7d13dc85
7 changed files with 539 additions and 267 deletions
+164 -163
View File
@@ -1,11 +1,10 @@
import { getDefaultSettings } from "../settings-manager"; import { getDefaultSettings } from "../settings-manager";
import { getState, setState, getCurrentPage } from "./reader-state"; import { getState, setState } from "./reader-state";
import { readerEvents } from "./reader-events"; import { readerEvents } from "./reader-events";
import { updateReadingProgress } from "./reader-services"; import { updateReadingProgress } from "./reader-services";
import { import {
calculatePagesForEbook, calculatePagesForEbook,
calculateProgressPercentage, calculateProgressPercentage,
getCurrentPageFromScroll,
type PageCalculationResult, type PageCalculationResult,
} from "../ebook/page-calculator"; } from "../ebook/page-calculator";
import { UniversalReader } from "../reader-shell"; import { UniversalReader } from "../reader-shell";
@@ -18,46 +17,45 @@ export function createNavigationAPI() {
nextPage: () => { nextPage: () => {
const state = getState(); const state = getState();
if (!state.currentReader) return; if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader); readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") { if (state.currentReader.type === "ebook") {
const container = document.getElementById("reader-content");
const viewportHeightAdjusted = window.innerHeight - 120;
if (pageCalculationResult) { if (pageCalculationResult) {
const currentChapter = pageCalculationResult.chapterMap.get( const currentPage = state.currentReader.currentPage || 1;
state.currentReader.currentSpineIndex,
);
if (currentChapter && container) { if (currentPage < pageCalculationResult.totalPages) {
const currentPageInChapter = Math.floor( const nextPage = currentPage + 1;
container.scrollTop / viewportHeightAdjusted, state.currentReader.currentPage = nextPage;
// Check if we need to move to next spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
); );
const pagesInCurrentChapter =
currentChapter.endPage - currentChapter.startPage + 1;
if (currentPageInChapter < pagesInCurrentChapter - 1) { if (currentChapter && nextPage > currentChapter.endPage) {
// Still pages left in current chapter - just scroll down // Move to next spine
container.scrollTop = if (
(currentPageInChapter + 1) * viewportHeightAdjusted; state.currentReader.currentSpineIndex <
} else if ( state.currentReader.cif.spine.length - 1
state.currentReader.currentSpineIndex < ) {
state.currentReader.cif.spine.length - 1 state.currentReader.currentSpineIndex++;
) { }
// At end of chapter, move to next spine
state.currentReader.currentSpineIndex++;
renderSpineItem();
} }
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
readerEvents.emit("pageChanged", nextPage);
} }
} else { } else {
// Fallback: just move to next spine // Fallback: spine-based navigation
if ( if (
state.currentReader.currentSpineIndex < state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1 state.currentReader.cif.spine.length - 1
) { ) {
state.currentReader.currentSpineIndex++; state.currentReader.currentSpineIndex++;
setState({ currentReader: state.currentReader });
renderSpineItem(); renderSpineItem();
sendProgressUpdate();
} }
} }
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
@@ -78,48 +76,54 @@ export function createNavigationAPI() {
renderComicPage(); renderComicPage();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", getCurrentPage());
readerEvents.emit("afterPageChange", state.currentReader); readerEvents.emit("afterPageChange", state.currentReader);
}, },
previousPage: () => { previousPage: () => {
const state = getState(); const state = getState();
if (!state.currentReader) return; if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader); readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") { if (state.currentReader.type === "ebook") {
const container = document.getElementById("reader-content");
const viewportHeightAdjusted = window.innerHeight - 120;
if (pageCalculationResult) { if (pageCalculationResult) {
const currentChapter = pageCalculationResult.chapterMap.get( const currentPage = state.currentReader.currentPage || 1;
state.currentReader.currentSpineIndex,
);
if (currentChapter && container) { if (currentPage > 1) {
const currentPageInChapter = Math.floor( const prevPage = currentPage - 1;
container.scrollTop / viewportHeightAdjusted,
// Check if we need to move to previous spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
); );
if (currentPageInChapter > 0) { if (currentChapter && prevPage < currentChapter.startPage) {
// Not at start of chapter - just scroll up // Move to previous spine
container.scrollTop = if (state.currentReader.currentSpineIndex > 0) {
(currentPageInChapter - 1) * viewportHeightAdjusted; state.currentReader.currentSpineIndex--;
} else if (state.currentReader.currentSpineIndex > 0) { const prevChapter = pageCalculationResult.chapterMap.get(
// At start of chapter, move to previous spine state.currentReader.currentSpineIndex,
state.currentReader.currentSpineIndex--; );
renderSpineItem(); state.currentReader.currentPage =
prevChapter?.endPage || prevPage;
} else {
state.currentReader.currentPage = 1;
}
} else {
state.currentReader.currentPage = prevPage;
} }
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
readerEvents.emit("pageChanged", state.currentReader.currentPage);
} }
} else { } else {
// Fallback: just move to previous spine // Fallback: spine-based navigation
if (state.currentReader.currentSpineIndex > 0) { if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--; state.currentReader.currentSpineIndex--;
setState({ currentReader: state.currentReader });
renderSpineItem(); renderSpineItem();
sendProgressUpdate();
} }
} }
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
@@ -136,23 +140,41 @@ export function createNavigationAPI() {
renderComicPage(); renderComicPage();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", getCurrentPage());
readerEvents.emit("afterPageChange", state.currentReader); readerEvents.emit("afterPageChange", state.currentReader);
}, },
goToPage: (page: number) => { goToPage: async (page: number) => {
const state = getState(); const state = getState();
if (!state.currentReader) return; if (!state.currentReader) return;
readerEvents.emit("beforePageChange", state.currentReader); readerEvents.emit("beforePageChange", state.currentReader);
if (state.currentReader.type === "ebook") { if (state.currentReader.type === "ebook") {
if (page >= 0 && page < state.currentReader.cif.spine.length) { if (pageCalculationResult) {
state.currentReader.currentSpineIndex = page; // Validate page number
renderSpineItem(); if (page < 1 || page > pageCalculationResult.totalPages) {
return;
}
// Find which spine contains this page
for (const chapter of pageCalculationResult.chapters) {
if (page >= chapter.startPage && page <= chapter.endPage) {
state.currentReader.currentSpineIndex = chapter.spineIndex;
state.currentReader.currentPage = page;
break;
}
}
setState({ currentReader: state.currentReader });
await renderSpineItem();
sendProgressUpdate();
} else {
// Fallback: treat page number as spine index
if (page >= 0 && page < state.currentReader.cif.spine.length) {
state.currentReader.currentSpineIndex = page;
state.currentReader.currentPage = page + 1;
setState({ currentReader: state.currentReader });
await renderSpineItem();
}
} }
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) { if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
@@ -163,14 +185,12 @@ export function createNavigationAPI() {
state.currentReader.type === "comic" || state.currentReader.type === "comic" ||
state.currentReader.type === "manga" state.currentReader.type === "manga"
) { ) {
if (page >= 0 && page < state.currentReader.images.length) { if (page >= 0 && page < (state.currentReader as any).images.length) {
state.currentReader.currentPage = page; state.currentReader.currentPage = page;
renderComicPage(); renderComicPage();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
sendProgressUpdate();
readerEvents.emit("pageChanged", page); readerEvents.emit("pageChanged", page);
readerEvents.emit("afterPageChange", state.currentReader); readerEvents.emit("afterPageChange", state.currentReader);
}, },
@@ -195,9 +215,7 @@ export async function initializePageCalculation() {
readerEvents.on("settings:changed", async (settings: any) => { readerEvents.on("settings:changed", async (settings: any) => {
const state = getState(); const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return; if (!state.currentReader || state.currentReader.type !== "ebook") return;
console.log("Recalculating pages due to settings change..."); console.log("Recalculating pages due to settings change...");
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
pageCalculationResult = await calculatePagesForEbook( pageCalculationResult = await calculatePagesForEbook(
state.currentReader.cif, state.currentReader.cif,
@@ -208,9 +226,21 @@ export async function initializePageCalculation() {
marginWidth: settings.margin_width, marginWidth: settings.margin_width,
}, },
); );
state.currentReader.pageCalculationResult = pageCalculationResult; state.currentReader.pageCalculationResult = pageCalculationResult;
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
// Re-render the current page with new settings
await renderSpineItem();
// Emit event so progress display updates
const currentPage = state.currentReader.currentPage || 1;
readerEvents.emit("progressUpdated", {
currentPage,
totalPages: pageCalculationResult.totalPages,
percentage: calculateProgressPercentage(
pageCalculationResult,
currentPage,
),
});
}); });
const state = getState(); const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook") return; if (!state.currentReader || state.currentReader.type !== "ebook") return;
@@ -250,104 +280,67 @@ export async function initializePageCalculation() {
export async function renderSpineItem() { export async function renderSpineItem() {
const state = getState(); const state = getState();
if (state.currentReader?.type !== "ebook") return; if (state.currentReader?.type !== "ebook") return;
if (!state.currentReader.currentPage) {
state.currentReader.currentPage = 1;
}
const container = document.getElementById("reader-content");
if (!container) return;
const spineItem = const spineItem =
state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
const container = document.getElementById("reader-content"); if (!spineItem) return;
if (pageCalculationResult) { // Get page calculation result
const viewportHeight = window.innerHeight; const chapter = pageCalculationResult?.chapterMap.get(
const currentPage = getCurrentPageFromScroll( state.currentReader.currentSpineIndex,
pageCalculationResult, );
state.currentReader.currentSpineIndex,
container.scrollTop,
viewportHeight,
);
// Store computed page for UI display // Calculate which page within the chapter we're on
state.currentReader.currentPage = currentPage; let currentPageContent = "";
setState({ currentReader: state.currentReader }); let currentPageNumber = state.currentReader.currentPage || 1;
const percentage = calculateProgressPercentage( if (chapter?.pages && chapter.pages.length > 0) {
pageCalculationResult, // Calculate which page of this spine item to show
currentPage, const globalPage = state.currentReader.currentPage || 1;
); const pageWithinChapter = Math.max(0, globalPage - chapter.startPage);
readerEvents.emit("progressUpdated", { const pageIndex = Math.min(pageWithinChapter, chapter.pages.length - 1);
currentPage,
totalPages: pageCalculationResult.totalPages, currentPageContent = chapter.pages[pageIndex]?.html || chapter.content;
percentage, currentPageNumber = chapter.startPage + pageIndex;
}); } else {
// Fallback: load full content if pages not calculated yet
const resources = state.currentReader.cif.resources;
const contentBlob = resources?.get(spineItem.content);
if (contentBlob) {
currentPageContent = await contentBlob.text();
} else {
console.error(
"Spine item content not found in resources:",
spineItem.content,
);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
return;
}
} }
if (!container || !spineItem) return; // Process and render the page content
// Get the actual content from resources using the href const modifiedContent = rewriteImageUrls(
const resources = state.currentReader.cif.resources; currentPageContent,
console.log("ALL resource keys:", Array.from(resources.keys())); state.currentReader.cif.resources,
const contentBlob = resources?.get(spineItem.content); );
if (!contentBlob) {
// Fallback: try to fetch directly if not in resources
console.error(
"Spine item content not found in resources:",
spineItem.content,
);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
container.addEventListener(
"scroll",
() => {
const state = getState();
if (!state.currentReader || state.currentReader.type !== "ebook")
return;
state.currentReader.currentScrollPosition = container.scrollTop; // Parse HTML and process images
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 },
);
return;
}
// Convert blob to text
const contentText = await contentBlob.text();
// Rewrite image src paths to use blob URLs from resources
const modifiedContent = rewriteImageUrls(contentText, resources);
const htmlImages = modifiedContent.match(/<img[^>]+>/g);
const svgImages =
modifiedContent.match(/<image[^>]+xlink:href="([^"]+)"[^>]*>/gi) || [];
const allImages = [...htmlImages, ...svgImages];
console.log("Found images in content:", allImages);
// Parse HTML and process images BEFORE setting innerHTML
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(modifiedContent, "text/html"); const doc = parser.parseFromString(modifiedContent, "text/html");
// Process <img> elements
const imgElements = Array.from(doc.querySelectorAll("img")); const imgElements = Array.from(doc.querySelectorAll("img"));
for (const img of imgElements) { for (const img of imgElements) {
const src = img.getAttribute("src"); const src = img.getAttribute("src");
if (!src) continue; if (!src) continue;
const blob = findImageInResources(state.currentReader.cif.resources, src);
// Try different path formats to find the image
let blob = findImageInResources(resources, src);
if (blob) { if (blob) {
// Create blob URL - need to do this properly img.setAttribute("src", URL.createObjectURL(blob));
const blobUrl = URL.createObjectURL(blob);
img.setAttribute("src", blobUrl);
} }
} }
@@ -356,18 +349,33 @@ export async function renderSpineItem() {
for (const img of svgImgElements) { for (const img of svgImgElements) {
const src = img.getAttribute("xlink:href"); const src = img.getAttribute("xlink:href");
if (!src) continue; if (!src) continue;
const blob = findImageInResources(state.currentReader.cif.resources, src);
let blob = findImageInResources(resources, src);
if (blob) { if (blob) {
const blobUrl = URL.createObjectURL(blob); img.setAttribute("xlink:href", URL.createObjectURL(blob));
img.setAttribute("xlink:href", blobUrl);
} }
} }
// Apply to container - replace innerHTML with current page content
container.innerHTML = doc.body.innerHTML; container.innerHTML = doc.body.innerHTML;
// Apply reader styling
// Apply styling
applyReaderTheme(); applyReaderTheme();
applyTypography(); applyTypography();
// Emit progress update
if (pageCalculationResult) {
state.currentReader.currentPage = currentPageNumber;
setState({ currentReader: state.currentReader });
readerEvents.emit("progressUpdated", {
currentPage: currentPageNumber,
totalPages: pageCalculationResult.totalPages,
percentage: calculateProgressPercentage(
pageCalculationResult,
currentPageNumber,
),
});
}
} }
function rewriteImageUrls( function rewriteImageUrls(
@@ -523,14 +531,7 @@ function sendProgressUpdate() {
if (state.currentReader.type === "ebook") { if (state.currentReader.type === "ebook") {
// Use dynamic page calculation if available // Use dynamic page calculation if available
if (pageCalculationResult) { if (pageCalculationResult) {
const container = document.getElementById("reader-content"); currentPage = state.currentReader.currentPage || 1;
const viewportHeight = window.innerHeight;
currentPage = getCurrentPageFromScroll(
pageCalculationResult,
state.currentReader.currentSpineIndex,
container?.scrollTop || 0,
viewportHeight,
);
totalPages = pageCalculationResult.totalPages; totalPages = pageCalculationResult.totalPages;
} else { } else {
currentPage = state.currentReader.currentSpineIndex + 1; currentPage = state.currentReader.currentSpineIndex + 1;
+1 -2
View File
@@ -35,9 +35,8 @@ export function getReaderMetadata(): ReaderMetadata | null {
export function getCurrentPage(): number { export function getCurrentPage(): number {
const reader = currentState.currentReader; const reader = currentState.currentReader;
if (!reader) return 0; if (!reader) return 0;
if (reader.type === "ebook") { if (reader.type === "ebook") {
return reader.currentSpineIndex; return reader.currentPage ?? reader.currentSpineIndex + 1;
} }
return reader.currentPage; return reader.currentPage;
} }
+19 -22
View File
@@ -1,4 +1,5 @@
import { findImageInResources } from "../core/reader-navigation"; import { findImageInResources } from "../core/reader-navigation";
import { splitContent, type PageContent } from "./page-splitter";
export interface ChapterPageInfo { export interface ChapterPageInfo {
spineIndex: number; spineIndex: number;
@@ -9,6 +10,7 @@ export interface ChapterPageInfo {
scrollHeight: number; scrollHeight: number;
charCount: number; charCount: number;
pagesInChapter: number; pagesInChapter: number;
pages: PageContent[];
} }
export interface PageCalculationResult { export interface PageCalculationResult {
@@ -111,14 +113,6 @@ async function renderContentForMeasurement(
return 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( export async function calculatePagesForEbook(
cif: EbookCIF, cif: EbookCIF,
viewportWidth: number, viewportWidth: number,
@@ -159,6 +153,7 @@ export async function calculatePagesForEbook(
scrollHeight: 0, scrollHeight: 0,
charCount: 0, charCount: 0,
pagesInChapter: 0, pagesInChapter: 0,
pages: [],
}); });
continue; continue;
} }
@@ -174,6 +169,7 @@ export async function calculatePagesForEbook(
scrollHeight: 0, scrollHeight: 0,
charCount: 0, charCount: 0,
pagesInChapter: 0, pagesInChapter: 0,
pages: [],
}); });
continue; continue;
} }
@@ -182,41 +178,42 @@ export async function calculatePagesForEbook(
const charCount = contentText.replace(/<[^>]*>/g, "").length; const charCount = contentText.replace(/<[^>]*>/g, "").length;
let scrollHeight = 0; let scrollHeight = 0;
let pagesInChapter = 1;
try { try {
const container = await renderContentForMeasurement( const container = await renderContentForMeasurement(
contentText, contentText,
cif.resources, cif.resources,
config, config,
); );
scrollHeight = container.scrollHeight; scrollHeight = container.scrollHeight;
pagesInChapter = calculatePageBreaks(scrollHeight, viewportHeight);
container.remove(); container.remove();
} catch (error) { } catch (error) {
console.warn( console.warn("Failed to measure content:", spineItem.id, error);
"Failed to measure content for spine item:",
spineItem.id,
error,
);
pagesInChapter = Math.max(1, Math.ceil(charCount / 1500));
} }
// Split content into discrete pages
const pageSplitResult = splitContent(
contentText,
i,
spineItem.id,
viewportHeight,
{ fontSize, lineHeight, marginWidth },
true, // useCFI
);
const chapterInfo: ChapterPageInfo = { const chapterInfo: ChapterPageInfo = {
spineIndex: i, spineIndex: i,
spineItemId: spineItem.id, spineItemId: spineItem.id,
content: contentText, content: contentText,
startPage: currentPage, startPage: currentPage,
endPage: currentPage + pagesInChapter - 1, endPage: currentPage + pageSplitResult.totalPages - 1,
scrollHeight, scrollHeight,
charCount, charCount,
pagesInChapter, pagesInChapter: pageSplitResult.totalPages,
pages: pageSplitResult.pages,
}; };
chapters.push(chapterInfo); chapters.push(chapterInfo);
currentPage += pagesInChapter; currentPage += pageSplitResult.totalPages;
} }
const chapterMap = new Map<number, ChapterPageInfo>(); const chapterMap = new Map<number, ChapterPageInfo>();
+276
View File
@@ -0,0 +1,276 @@
// Page splitter for ebooks - extracts discrete page content
// Uses CFI for EPUB when available, falls back to height-based splitting
import { generateCFI } from "./cfi-navigator";
export interface PageContent {
pageNumber: number;
spineIndex: number;
startCFI?: string;
endCFI?: string;
html: string;
charCount: number;
}
export interface PageSplitResult {
pages: PageContent[];
totalPages: number;
}
// Split content by viewport height (fallback for non-EPUB)
export function splitByHeight(
html: string,
spineIndex: number,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Clone body content
const content = doc.body;
const contentHeight = estimateContentHeight(content, settings);
const contentWidth = doc.body.scrollWidth || 600;
const pageCount = Math.max(1, Math.ceil(contentHeight / viewportHeight));
// For height-based splitting, we'll use a different approach:
// Wrap each block element and measure cumulative height
const blocks = Array.from(content.children);
let currentPageHTML = "";
let currentHeight = 0;
let pageNumber = 1;
for (const block of blocks) {
const blockHeight = estimateBlockHeight(block, settings, contentWidth);
if (
currentHeight + blockHeight > viewportHeight &&
currentPageHTML.length > 0
) {
// Save current page and start new one
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageHTML = "";
currentHeight = 0;
}
currentPageHTML += block.outerHTML;
currentHeight += blockHeight;
}
// Push final page
if (currentPageHTML.length > 0 || pages.length === 0) {
pages.push({
pageNumber,
spineIndex,
html: wrapInPageContainer(currentPageHTML, pageNumber, pageCount),
charCount: currentPageHTML.replace(/<[^>]*>/g, "").length,
});
}
return { pages, totalPages: pages.length };
}
// Split content using CFI (for EPUB)
export function splitByCFI(
html: string,
spineIndex: number,
spineItemId: string,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
): PageSplitResult {
console.log("splitByCFI called:", {
htmlLength: html.length,
viewportHeight,
spineIndex,
});
const pages: PageContent[] = [];
const doc = new DOMParser().parseFromString(html, "text/html");
// Find all text-containing elements (paragraphs, divs, etc.)
const elements = findTextElements(doc.body);
let currentPageElements: Element[] = [];
let currentHeight = 0;
const contentWidth = doc.body.scrollWidth || 600;
let pageNumber = 1;
let elementIndex = 0;
for (const element of elements) {
const elementHeight = estimateBlockHeight(element, settings, contentWidth);
if (
currentHeight + elementHeight > viewportHeight &&
currentPageElements.length > 0
) {
// Create page from accumulated elements
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, 0), // pageCount TBD
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
pageNumber++;
currentPageElements = [];
currentHeight = 0;
}
currentPageElements.push(element);
currentHeight += elementHeight;
elementIndex++;
}
// Push final page
if (currentPageElements.length > 0) {
const pageHTML = elementsToHTML(currentPageElements);
const startIndex = elements.indexOf(currentPageElements[0]);
const endIndex = elements.indexOf(
currentPageElements[currentPageElements.length - 1],
);
pages.push({
pageNumber,
spineIndex,
startCFI: generateCFI(
spineIndex,
buildElementPath(elements[startIndex], doc.body),
0,
spineItemId,
),
endCFI: generateCFI(
spineIndex,
buildElementPath(elements[endIndex], doc.body),
0,
spineItemId,
),
html: wrapInPageContainer(pageHTML, pageNumber, pageNumber),
charCount: pageHTML.replace(/<[^>]*>/g, "").length,
});
}
console.log("splitByCFI result:", {
totalPages: pages.length,
elementsFound: elements.length,
contentWidth,
});
return { pages, totalPages: pages.length };
}
// Helper: Estimate content height
function estimateContentHeight(element: Element, settings: any): number {
const text = element.textContent || "";
const charCount = text.length;
const charsPerLine = Math.floor(
(800 - settings.marginWidth * 2) / (settings.fontSize * 0.6),
);
const lineCount = Math.ceil(charCount / charsPerLine);
return lineCount * settings.fontSize * settings.lineHeight;
}
// Helper: Estimate block height
function estimateBlockHeight(
element: Element,
settings: any,
contentWidth: number,
): number {
const text = element.textContent || "";
const approxChars = text.length;
const lineHeight = settings.fontSize * settings.lineHeight;
// Use actual content width instead of hardcoded 800
const charsPerLine = Math.floor(contentWidth / (settings.fontSize * 0.6));
const lineCount = Math.max(1, Math.ceil(approxChars / charsPerLine));
return lineCount * lineHeight + 16;
}
// Helper: Find text-containing elements using querySelectorAll
function findTextElements(element: Element): Element[] {
const selector = [
"p",
"div",
"span",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"td",
"th",
"article",
"section",
"blockquote",
"pre",
"figcaption",
"header",
"footer",
"main",
"aside",
].join(",");
return Array.from(element.querySelectorAll(selector)).filter((el) => {
const text = el.textContent?.trim() || "";
return text.length > 0;
});
}
// Helper: Convert elements to HTML string
function elementsToHTML(elements: Element[]): string {
return elements.map((el) => el.outerHTML).join("");
}
// Helper: Build CFI element path
function buildElementPath(element: Element, root: Element): number[] {
const path: number[] = [];
let current: Element | null = element;
while (current && current !== root) {
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(current);
path.unshift(index);
current = parent;
} else {
break;
}
}
return path;
}
// Helper: Wrap content in page container
function wrapInPageContainer(
html: string,
current: number,
total: number,
): string {
return `<div class="ebook-page" data-page="${current}" data-total="${total}">${html}</div>`;
}
// Main entry point - choose method based on format
export function splitContent(
html: string,
spineIndex: number,
spineItemId: string | null,
viewportHeight: number,
settings: { fontSize: number; lineHeight: number; marginWidth: number },
useCFI: boolean = true,
): PageSplitResult {
if (useCFI && spineItemId) {
return splitByCFI(html, spineIndex, spineItemId, viewportHeight, settings);
}
return splitByHeight(html, spineIndex, viewportHeight, settings);
}
+41 -57
View File
@@ -56,10 +56,10 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
switch (mode) { switch (mode) {
case "paginated": case "paginated":
applyPaginatedMode(container, element); applyPaginatedMode(element);
break; break;
case "scrolled": case "scrolled":
applyScrolledMode(container, element); applyScrolledMode(element);
break; break;
case "single-column": case "single-column":
applySingleColumn(element); applySingleColumn(element);
@@ -70,28 +70,56 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
} }
} }
function applyPaginatedMode( function applyPaginatedMode(element: HTMLElement): void {
container: HTMLElement,
element: HTMLElement,
): void {
element.classList.add("paginated"); element.classList.add("paginated");
element.style.height = "100vh"; // True pagination: content fits exactly in viewport, no scrolling
element.style.height = "calc(100vh - 120px)"; // Account for chrome (top 60px + bottom 60px)
element.style.overflow = "hidden"; element.style.overflow = "hidden";
element.style.columnCount = "1"; element.style.columnCount = "1";
element.style.columnGap = "0"; element.style.columnGap = "0";
element.style.position = "relative";
enablePagination(container, element); // Inject CSS for page clipping
injectPaginatedStyles();
} }
function applyScrolledMode(container: HTMLElement, element: HTMLElement): void { function injectPaginatedStyles(): void {
// Remove existing injected styles if any
const existing = document.getElementById("paginated-styles");
existing?.remove();
const style = document.createElement("style");
style.id = "paginated-styles";
style.textContent = `
.paginated .ebook-page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.paginated .ebook-page > * {
max-height: 100%;
overflow: hidden;
}
.paginated .ebook-content {
height: 100%;
overflow: hidden !important;
position: relative;
}
.paginated .ebook-content * {
overflow-wrap: break-word;
}
`;
document.head.appendChild(style);
}
function applyScrolledMode(element: HTMLElement): void {
element.classList.add("scrolled"); element.classList.add("scrolled");
element.style.height = "auto"; element.style.height = "auto";
element.style.overflowY = "auto"; element.style.overflowY = "auto";
element.style.columnCount = "1"; element.style.columnCount = "1";
disablePagination(container);
} }
function applySingleColumn(element: HTMLElement): void { function applySingleColumn(element: HTMLElement): void {
@@ -113,51 +141,6 @@ function applyDoubleColumn(element: HTMLElement): void {
element.style.margin = "0 auto"; element.style.margin = "0 auto";
} }
function enablePagination(container: HTMLElement, element: HTMLElement): void {
const totalHeight = element.scrollHeight;
const pageHeight = element.clientHeight;
const pageCount = Math.ceil(totalHeight / pageHeight);
addPaginationControls(container, pageCount);
}
function disablePagination(container: HTMLElement): void {
const controls = container.querySelector(".pagination-controls");
controls?.remove();
}
function addPaginationControls(
container: HTMLElement,
pageCount: number,
): void {
let currentPage = 1;
const controls = document.createElement("div");
controls.className =
"pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t";
controls.innerHTML = `
<button class="prev-page" ${currentPage === 1 ? "disabled" : ""}>← Previous</button>
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
<button class="next-page" ${currentPage === pageCount ? "disabled" : ""}>Next →</button>
`;
controls.querySelector(".prev-page")?.addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
goToPage(container, currentPage);
}
});
controls.querySelector(".next-page")?.addEventListener("click", () => {
if (currentPage < pageCount) {
currentPage++;
goToPage(container, currentPage);
}
});
container.appendChild(controls);
}
function goToPage(container: HTMLElement, pageNumber: number): void { function goToPage(container: HTMLElement, pageNumber: number): void {
const content = container.querySelector(".ebook-content") as HTMLElement; const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return; if (!content) return;
@@ -196,4 +179,5 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
if (content.classList.contains("double-column")) return "double-column"; if (content.classList.contains("double-column")) return "double-column";
return "paginated"; return "paginated";
} }
+30 -16
View File
@@ -1,5 +1,4 @@
import type { ReaderContext } from "../core/reader-context"; import type { ReaderContext } from "../core/reader-context";
import { getCurrentPageFromScroll } from "../ebook/page-calculator";
interface ProgressDisplay { interface ProgressDisplay {
mode: "pages" | "chapter" | "percentage" | "time-left"; mode: "pages" | "chapter" | "percentage" | "time-left";
text: string; text: string;
@@ -111,35 +110,50 @@ function updateProgressDisplay(context: ReaderContext): void {
if (!display || !state.currentReader) return; if (!display || !state.currentReader) return;
let currentPage = 0; let currentPage = 0;
let totalPages = 0; let totalPages = 0;
let currentChapterPage = 0;
let chapterPages = 0;
if (state.currentReader.type === "ebook") { if (state.currentReader.type === "ebook") {
const reader = state.currentReader as any; const reader = state.currentReader as any;
const pageInfo = reader.pageCalculationResult;
if (pageInfo && pageInfo.totalPages > 0) { // Use currentPage directly if available (page-based navigation)
const container = document.getElementById("reader-content"); if (reader.currentPage) {
const viewportHeight = window.innerHeight; currentPage = reader.currentPage;
currentPage = getCurrentPageFromScroll( } else if (reader.currentSpineIndex !== undefined) {
pageInfo,
reader.currentSpineIndex,
container?.scrollTop || 0,
viewportHeight,
);
totalPages = pageInfo.totalPages;
} else {
// Fallback to estimated pages while calculating
currentPage = reader.currentSpineIndex + 1; currentPage = reader.currentSpineIndex + 1;
}
// Get total pages from calculation result
const pageInfo = reader.pageCalculationResult;
if (pageInfo && pageInfo.totalPages > 0) {
totalPages = pageInfo.totalPages;
// Get chapter progress
const chapter = pageInfo.chapterMap.get(reader.currentSpineIndex);
if (chapter) {
currentChapterPage = currentPage - chapter.startPage + 1;
chapterPages = chapter.pagesInChapter;
}
} else {
// Fallback
totalPages = totalPages =
state.readerMetadata?.total_pages || state.readerMetadata?.total_pages ||
state.currentReader.cif.locations?.estimatedPages || state.currentReader.cif.locations?.estimatedPages ||
state.currentReader.cif.spine.length; state.currentReader.cif.spine.length;
} }
} else if (state.currentReader.type === "pdf") {
currentPage = state.currentReader.currentPage;
totalPages = state.readerMetadata?.total_pages || 0;
} else {
// Comic/manga
currentPage = (state.currentReader as any).currentPage + 1;
totalPages = (state.currentReader as any).images?.length || 0;
} }
getReadingSpeed().then((speed) => { getReadingSpeed().then((speed) => {
const result = calculateProgress( const result = calculateProgress(
currentPage, currentPage,
totalPages, totalPages,
0, currentChapterPage,
0, chapterPages,
speed || undefined, speed || undefined,
); );
display.textContent = result.text; display.textContent = result.text;
+8 -7
View File
@@ -11,6 +11,8 @@ export interface UniversalReader {
type: "ebook"; type: "ebook";
cif: any; cif: any;
currentSpineIndex: number; currentSpineIndex: number;
currentPage: number;
pageCalculationResult?: any;
} }
interface PDFReader { interface PDFReader {
@@ -143,17 +145,15 @@ async function initializeReader(): Promise<void> {
await initializeFeatures(context); await initializeFeatures(context);
readerEvents.emit("readerReady", currentReader); readerEvents.emit("readerReady", currentReader);
// Render the initial chapter content // Initialize page calculation FIRST, before any rendering
const { initializePageCalculation } =
await import("./core/reader-navigation");
await initializePageCalculation();
// Then render the initial chapter content
await renderSpineItem(); await renderSpineItem();
// Focus the content container so keyboard navigation works immediately // Focus the content container so keyboard navigation works immediately
const container = document.getElementById("reader-content"); const container = document.getElementById("reader-content");
container?.focus(); container?.focus();
// Initialize page calculation for dynamic page numbers
setTimeout(async () => {
const { initializePageCalculation } =
await import("./core/reader-navigation");
initializePageCalculation();
}, 100);
} catch (error) { } catch (error) {
console.error("Render initialization failed:", error); console.error("Render initialization failed:", error);
} }
@@ -206,6 +206,7 @@ async function initializeEbookReader(metadata: any): Promise<UniversalReader> {
type: "ebook", type: "ebook",
cif, cif,
currentSpineIndex: 0, currentSpineIndex: 0,
currentPage: 1,
}; };
} }