refactor(ebook-reader): switch to CSS columns pagination approach

Replace HTML-splitting pagination with CSS columns for true paginated
viewing. This simplifies the implementation and relies on the browser's
native column-fill behavior for accurate page breaks.

Changes:
- view-modes.ts: Use CSS columns with column-fill: auto instead of
  pre-splitting HTML content into page chunks. Calculate page count
  from scrollHeight / viewportHeight.

- reader-navigation.ts: Navigate by scrolling viewport height instead of
  extracting discrete page content. Track page position via scroll
  offset. Simplified renderSpineItem to load full spine content.

- page-calculator.ts: Simplified to track spine info (charCount,
  estimatedPages) only. No more height-based content splitting.
  Page calculation happens in real-time from DOM scroll position.

Benefits:
- Accurate pagination without estimation errors
- Works correctly across different font sizes and screen sizes
- Simpler code with fewer edge cases
- Natural page breaks at element boundaries via CSS
This commit is contained in:
2026-04-07 21:02:18 -04:00
parent 1e7d13dc85
commit 1a5eb40d82
3 changed files with 234 additions and 498 deletions
+154 -260
View File
@@ -2,16 +2,8 @@ import { getDefaultSettings } from "../settings-manager";
import { getState, setState } 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 {
calculatePagesForEbook,
calculateProgressPercentage,
type PageCalculationResult,
} from "../ebook/page-calculator";
import { UniversalReader } from "../reader-shell"; import { UniversalReader } from "../reader-shell";
let pageCalculationResult: PageCalculationResult | null = null;
let isCalculatingPages = false;
export function createNavigationAPI() { export function createNavigationAPI() {
return { return {
nextPage: () => { nextPage: () => {
@@ -19,50 +11,43 @@ export function createNavigationAPI() {
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 (pageCalculationResult) { const container = document.getElementById("reader-content");
const currentPage = state.currentReader.currentPage || 1; if (!container) return;
const viewportHeight = window.innerHeight - 120;
if (currentPage < pageCalculationResult.totalPages) { const currentScroll = container.scrollTop;
const nextPage = currentPage + 1; const newScroll = currentScroll + viewportHeight;
state.currentReader.currentPage = nextPage; if (newScroll >= container.scrollHeight - viewportHeight) {
// Check if we need to move to next spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
if (currentChapter && nextPage > currentChapter.endPage) {
// Move to next spine
if (
state.currentReader.currentSpineIndex <
state.currentReader.cif.spine.length - 1
) {
state.currentReader.currentSpineIndex++;
}
}
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
readerEvents.emit("pageChanged", nextPage);
}
} else {
// 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 }); setState({ currentReader: state.currentReader });
renderSpineItem(); renderSpineItem().then(() => {
const newContainer = document.getElementById("reader-content");
if (newContainer) newContainer.scrollTop = 0;
sendProgressUpdate();
});
} else {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
sendProgressUpdate(); sendProgressUpdate();
} }
} else {
container.scrollTo({
top: newScroll,
behavior: "smooth",
});
sendProgressUpdate();
} }
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
const totalPages = state.readerMetadata?.total_pages || 0; const totalPages = state.readerMetadata?.total_pages || 0;
if (state.currentReader.currentPage < totalPages) { if (state.currentReader.currentPage < totalPages) {
state.currentReader.currentPage++; state.currentReader.currentPage++;
renderPDFPage(); renderPDFPage();
sendProgressUpdate();
} }
} else if ( } else if (
state.currentReader.type === "comic" || state.currentReader.type === "comic" ||
@@ -74,62 +59,46 @@ export function createNavigationAPI() {
) { ) {
state.currentReader.currentPage++; state.currentReader.currentPage++;
renderComicPage(); renderComicPage();
sendProgressUpdate();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
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") {
if (pageCalculationResult) { const container = document.getElementById("reader-content");
const currentPage = state.currentReader.currentPage || 1; if (!container) return;
const currentScroll = container.scrollTop;
if (currentPage > 1) { if (currentScroll <= 0) {
const prevPage = currentPage - 1;
// Check if we need to move to previous spine
const currentChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
if (currentChapter && prevPage < currentChapter.startPage) {
// Move to previous spine
if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--;
const prevChapter = pageCalculationResult.chapterMap.get(
state.currentReader.currentSpineIndex,
);
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 {
// Fallback: spine-based navigation
if (state.currentReader.currentSpineIndex > 0) { if (state.currentReader.currentSpineIndex > 0) {
state.currentReader.currentSpineIndex--; state.currentReader.currentSpineIndex--;
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
renderSpineItem(); renderSpineItem().then(() => {
sendProgressUpdate(); const newContainer = document.getElementById("reader-content");
if (newContainer) {
newContainer.scrollTop = newContainer.scrollHeight;
}
sendProgressUpdate();
});
} }
} else {
const viewportHeight = window.innerHeight - 120;
const newScroll = currentScroll - viewportHeight;
container.scrollTo({
top: Math.max(0, newScroll),
behavior: "smooth",
});
sendProgressUpdate();
} }
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
if (state.currentReader.currentPage > 1) { if (state.currentReader.currentPage > 1) {
state.currentReader.currentPage--; state.currentReader.currentPage--;
renderPDFPage(); renderPDFPage();
sendProgressUpdate();
} }
} else if ( } else if (
state.currentReader.type === "comic" || state.currentReader.type === "comic" ||
@@ -138,48 +107,41 @@ export function createNavigationAPI() {
if (state.currentReader.currentPage > 0) { if (state.currentReader.currentPage > 0) {
state.currentReader.currentPage--; state.currentReader.currentPage--;
renderComicPage(); renderComicPage();
sendProgressUpdate();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
readerEvents.emit("afterPageChange", state.currentReader); readerEvents.emit("afterPageChange", state.currentReader);
}, },
goToPage: async (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 (pageCalculationResult) { const spine = state.currentReader.cif.spine;
// Validate page number const spineCount = spine.length;
if (page < 1 || page > pageCalculationResult.totalPages) { const pagesPerSpine = Math.ceil(1000 / spineCount);
return; const targetSpineIndex = Math.min(
Math.floor((page - 1) / pagesPerSpine),
spineCount - 1,
);
state.currentReader.currentSpineIndex = targetSpineIndex;
setState({ currentReader: state.currentReader });
await renderSpineItem();
setTimeout(() => {
const container = document.getElementById("reader-content");
if (container) {
const viewportHeight = window.innerHeight - 120;
const pageInSpine = page - targetSpineIndex * pagesPerSpine;
const scrollTop = Math.max(0, (pageInSpine - 1) * viewportHeight);
container.scrollTop = scrollTop;
} }
}, 100);
// 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)) {
state.currentReader.currentPage = page; state.currentReader.currentPage = page;
renderPDFPage(); renderPDFPage();
sendProgressUpdate();
} }
} else if ( } else if (
state.currentReader.type === "comic" || state.currentReader.type === "comic" ||
@@ -188,152 +150,98 @@ export function createNavigationAPI() {
if (page >= 0 && page < (state.currentReader as any).images.length) { if (page >= 0 && page < (state.currentReader as any).images.length) {
state.currentReader.currentPage = page; state.currentReader.currentPage = page;
renderComicPage(); renderComicPage();
sendProgressUpdate();
} }
} }
setState({ currentReader: state.currentReader }); setState({ currentReader: state.currentReader });
readerEvents.emit("pageChanged", page); readerEvents.emit("pageChanged", page);
readerEvents.emit("afterPageChange", state.currentReader); readerEvents.emit("afterPageChange", state.currentReader);
}, },
goToChapter: (chapterIndex: number) => { goToChapter: (chapterIndex: number) => {
const state = getState(); const state = getState();
if (!state.readerMetadata?.chapter_metadata?.chapters) return; if (!state.readerMetadata?.chapter_metadata?.chapters) return;
if (state.currentReader?.type !== "ebook") return;
const chapters = state.readerMetadata.chapter_metadata.chapters; const chapters = state.readerMetadata.chapter_metadata.chapters;
if (chapterIndex < 0 || chapterIndex >= chapters.length) return; if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
const chapter = chapters[chapterIndex]; const chapter = chapters[chapterIndex];
const pageAPI = createNavigationAPI(); if ((chapter as any).spine_index !== undefined) {
pageAPI.goToPage(chapter.start_page); state.currentReader.currentSpineIndex = (chapter as any).spine_index;
setState({ currentReader: state.currentReader });
renderSpineItem();
sendProgressUpdate();
} else {
const pageAPI = createNavigationAPI();
pageAPI.goToPage(chapter.start_page);
}
readerEvents.emit("chapterChanged", chapter); readerEvents.emit("chapterChanged", chapter);
}, },
}; };
} }
export async function initializePageCalculation() { export async function initializePageCalculation() {
readerEvents.on("settings:changed", async (settings: any) => { setupScrollTracking();
const state = getState(); console.log("Page tracking initialized (CSS columns mode)");
if (!state.currentReader || state.currentReader.type !== "ebook") return; }
console.log("Recalculating pages due to settings change..."); function setupScrollTracking(): void {
const viewportWidth = window.innerWidth; let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
pageCalculationResult = await calculatePagesForEbook( document.addEventListener(
state.currentReader.cif, "scroll",
viewportWidth, () => {
{ const container = document.getElementById("reader-content");
fontSize: settings.font_size, if (!container) return;
lineHeight: settings.line_height, const state = getState();
marginWidth: settings.margin_width, if (!state.currentReader || state.currentReader.type !== "ebook") return;
}, if (scrollTimeout) clearTimeout(scrollTimeout);
); scrollTimeout = setTimeout(() => {
state.currentReader.pageCalculationResult = pageCalculationResult; updatePageFromScroll(container);
setState({ currentReader: state.currentReader }); }, 50);
// Re-render the current page with new settings },
await renderSpineItem(); { passive: true },
);
// Emit event so progress display updates }
const currentPage = state.currentReader.currentPage || 1; function updatePageFromScroll(container: HTMLElement): void {
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;
if (isCalculatingPages) return; const viewportHeight = window.innerHeight - 120;
const scrollTop = container.scrollTop;
isCalculatingPages = true; const contentHeight = container.scrollHeight;
const currentPage = Math.floor(scrollTop / viewportHeight) + 1;
try { const totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
const settings = getDefaultSettings(); const percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
const viewportWidth = window.innerWidth; state.currentReader.currentPage = currentPage;
state.currentReader.currentScrollPosition = scrollTop;
pageCalculationResult = await calculatePagesForEbook( setState({ currentReader: state.currentReader });
state.currentReader.cif, readerEvents.emit("progressUpdated", {
viewportWidth, currentPage,
{ totalPages,
fontSize: settings.font_size, percentage,
lineHeight: settings.line_height, scrollTop,
marginWidth: settings.margin_width, contentHeight,
}, });
);
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;
}
} }
export async function renderSpineItem() { export async function renderSpineItem(): Promise<void> {
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"); const container = document.getElementById("reader-content");
if (!container) return; if (!container) return;
const spineItem = const spineItem =
state.currentReader.cif.spine[state.currentReader.currentSpineIndex]; state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
if (!spineItem) return; if (!spineItem) return;
// Get page calculation result const resources = state.currentReader.cif.resources;
const chapter = pageCalculationResult?.chapterMap.get( const contentBlob = resources?.get(spineItem.content);
state.currentReader.currentSpineIndex, if (!contentBlob) {
); console.error("Spine item content not found:", spineItem.content);
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
// Calculate which page within the chapter we're on return;
let currentPageContent = "";
let currentPageNumber = state.currentReader.currentPage || 1;
if (chapter?.pages && chapter.pages.length > 0) {
// Calculate which page of this spine item to show
const globalPage = state.currentReader.currentPage || 1;
const pageWithinChapter = Math.max(0, globalPage - chapter.startPage);
const pageIndex = Math.min(pageWithinChapter, chapter.pages.length - 1);
currentPageContent = chapter.pages[pageIndex]?.html || chapter.content;
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;
}
} }
// Process and render the page content let currentPageContent = await contentBlob.text();
const modifiedContent = rewriteImageUrls( const modifiedContent = rewriteImageUrls(
currentPageContent, currentPageContent,
state.currentReader.cif.resources, state.currentReader.cif.resources,
); );
// Parse HTML and process images
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");
@@ -343,8 +251,6 @@ export async function renderSpineItem() {
img.setAttribute("src", URL.createObjectURL(blob)); img.setAttribute("src", URL.createObjectURL(blob));
} }
} }
// Process SVG <image> elements with xlink:href
const svgImgElements = Array.from(doc.querySelectorAll("image")); const svgImgElements = Array.from(doc.querySelectorAll("image"));
for (const img of svgImgElements) { for (const img of svgImgElements) {
const src = img.getAttribute("xlink:href"); const src = img.getAttribute("xlink:href");
@@ -354,28 +260,12 @@ export async function renderSpineItem() {
img.setAttribute("xlink:href", URL.createObjectURL(blob)); img.setAttribute("xlink:href", URL.createObjectURL(blob));
} }
} }
container.innerHTML = `<div class="ebook-content">${doc.body.innerHTML}</div>`;
// Apply to container - replace innerHTML with current page content
container.innerHTML = doc.body.innerHTML;
// Apply styling
applyReaderTheme(); applyReaderTheme();
applyTypography(); applyTypography();
requestAnimationFrame(() => {
// Emit progress update updatePageFromScroll(container);
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(
@@ -511,32 +401,32 @@ function getFontStack(font: string): string {
return stacks[font] || stacks["literata"]; return stacks[font] || stacks["literata"];
} }
function sendProgressUpdate() { function sendProgressUpdate(): void {
const state = getState(); const state = getState();
if (!state.currentReader || !state.readerMetadata) return; if (!state.currentReader || !state.readerMetadata) return;
const mediaItemId = const mediaItemId =
state.readerMetadata.id || state.readerMetadata.id ||
document.body.dataset.mediaItemId || document.body.dataset.mediaItemId ||
window.location.pathname.split("/").pop(); window.location.pathname.split("/").pop();
if (!mediaItemId) { if (!mediaItemId) {
console.warn("No mediaItemId available for progress update"); console.warn("No mediaItemId available for progress update");
return; return;
} }
const container = document.getElementById("reader-content");
let currentPage = 0; let currentPage = 1;
let totalPages = 0; let totalPages = 1;
let percentage = 0;
if (state.currentReader.type === "ebook") { let character = 0;
// Use dynamic page calculation if available if (state.currentReader.type === "ebook" && container) {
if (pageCalculationResult) { const viewportHeight = window.innerHeight - 120;
currentPage = state.currentReader.currentPage || 1; const contentHeight = container.scrollHeight;
totalPages = pageCalculationResult.totalPages; const scrollTop = container.scrollTop;
} else { currentPage = Math.floor(scrollTop / viewportHeight) + 1;
currentPage = state.currentReader.currentSpineIndex + 1; totalPages = Math.max(1, Math.ceil(contentHeight / viewportHeight));
totalPages = state.currentReader.cif.spine.length; percentage = contentHeight > 0 ? (scrollTop / contentHeight) * 100 : 0;
} character = getCharacterOffset();
state.currentReader.currentPage = currentPage;
setState({ currentReader: state.currentReader });
} else if (state.currentReader.type === "pdf") { } else if (state.currentReader.type === "pdf") {
totalPages = state.readerMetadata.total_pages || 0; totalPages = state.readerMetadata.total_pages || 0;
currentPage = state.currentReader.currentPage; currentPage = state.currentReader.currentPage;
@@ -547,10 +437,7 @@ function sendProgressUpdate() {
totalPages = state.currentReader.images.length; totalPages = state.currentReader.images.length;
currentPage = state.currentReader.currentPage; currentPage = state.currentReader.currentPage;
} }
const reader = state.currentReader as UniversalReader; const reader = state.currentReader as UniversalReader;
const percentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
updateReadingProgress( updateReadingProgress(
state.readerMetadata.id, state.readerMetadata.id,
{ {
@@ -558,19 +445,26 @@ function sendProgressUpdate() {
total_pages: totalPages, total_pages: totalPages,
}, },
{ {
// Extended data for cross-device sync character,
character: getCharacterOffset(),
chapter: reader.currentSpineIndex, chapter: reader.currentSpineIndex,
percentage: percentage, percentage,
}, },
); );
readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage });
readerEvents.emit("progressUpdated", { currentPage, totalPages });
} }
function getCharacterOffset(): number { function getCharacterOffset(): number {
const container = document.getElementById("reader-content"); const container = document.getElementById("reader-content");
if (!container) return 0; if (!container) return 0;
const textContent = container.textContent || ""; const viewportHeight = window.innerHeight - 120;
return textContent.length; const scrollTop = container.scrollTop;
const viewportIndex = Math.floor(scrollTop / viewportHeight);
const allText = container.textContent || "";
const totalChars = allText.length;
const viewportCount = Math.max(
1,
Math.ceil(container.scrollHeight / viewportHeight),
);
const charsPerViewport = totalChars / viewportCount;
return Math.floor(viewportIndex * charsPerViewport);
} }
+54 -203
View File
@@ -1,121 +1,25 @@
import { findImageInResources } from "../core/reader-navigation"; export interface SpineInfo {
import { splitContent, type PageContent } from "./page-splitter";
export interface ChapterPageInfo {
spineIndex: number; spineIndex: number;
spineItemId: string; spineItemId: string;
content: string; content: string;
startPage: number;
endPage: number;
scrollHeight: number;
charCount: number; charCount: number;
pagesInChapter: number; estimatedPages: number;
pages: PageContent[];
} }
export interface PageCalculationResult { export interface PageCalculationResult {
totalPages: number; totalPages: number;
chapters: ChapterPageInfo[]; spines: SpineInfo[];
chapterMap: Map<number, ChapterPageInfo>; spineMap: Map<number, SpineInfo>;
calculatedAt: number; calculatedAt: number;
settings: PageCalculationSettings;
} }
export interface PageCalculationSettings { export interface PageCalculationSettings {
fontSize: number; fontSize: number;
lineHeight: number; lineHeight: number;
marginWidth: number; marginWidth: number;
viewportHeight: 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 = Array.from(doc.querySelectorAll("img"));
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
let blob = findImageInResources(resources, 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;
}
export async function calculatePagesForEbook( export async function calculatePagesForEbook(
cif: EbookCIF, cif: EbookCIF,
viewportWidth: number, _viewportWidth: number,
settings: { settings: {
fontSize?: number; fontSize?: number;
lineHeight?: number; lineHeight?: number;
@@ -124,156 +28,103 @@ export async function calculatePagesForEbook(
): Promise<PageCalculationResult> { ): Promise<PageCalculationResult> {
const fontSize = settings.fontSize || 16; const fontSize = settings.fontSize || 16;
const lineHeight = settings.lineHeight || 1.6; const lineHeight = settings.lineHeight || 1.6;
const marginWidth = settings.marginWidth || 20; const charsPerPage = Math.round(2000 * (16 / fontSize) * (lineHeight / 1.6));
const spines: SpineInfo[] = [];
const viewportHeight = window.innerHeight - 120; let estimatedTotalPages = 0;
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++) { for (let i = 0; i < cif.spine.length; i++) {
const spineItem = cif.spine[i]; const spineItem = cif.spine[i];
if (spineItem.type !== "html") { if (spineItem.type !== "html") {
chapters.push({ spines.push({
spineIndex: i, spineIndex: i,
spineItemId: spineItem.id, spineItemId: spineItem.id,
content: "", content: "",
startPage: currentPage,
endPage: currentPage,
scrollHeight: 0,
charCount: 0, charCount: 0,
pagesInChapter: 0, estimatedPages: 1,
pages: [],
}); });
estimatedTotalPages += 1;
continue; continue;
} }
const contentBlob = cif.resources.get(spineItem.content); const contentBlob = cif.resources.get(spineItem.content);
if (!contentBlob) { if (!contentBlob) {
chapters.push({ spines.push({
spineIndex: i, spineIndex: i,
spineItemId: spineItem.id, spineItemId: spineItem.id,
content: "", content: "",
startPage: currentPage,
endPage: currentPage,
scrollHeight: 0,
charCount: 0, charCount: 0,
pagesInChapter: 0, estimatedPages: 1,
pages: [],
}); });
estimatedTotalPages += 1;
continue; continue;
} }
const contentText = await contentBlob.text(); const contentText = await contentBlob.text();
const charCount = contentText.replace(/<[^>]*>/g, "").length; const charCount = contentText.replace(/<[^>]*>/g, "").length;
const estimatedPages = Math.max(1, Math.ceil(charCount / charsPerPage));
let scrollHeight = 0; estimatedTotalPages += estimatedPages;
try { spines.push({
const container = await renderContentForMeasurement(
contentText,
cif.resources,
config,
);
scrollHeight = container.scrollHeight;
container.remove();
} catch (error) {
console.warn("Failed to measure content:", spineItem.id, error);
}
// Split content into discrete pages
const pageSplitResult = splitContent(
contentText,
i,
spineItem.id,
viewportHeight,
{ fontSize, lineHeight, marginWidth },
true, // useCFI
);
const chapterInfo: ChapterPageInfo = {
spineIndex: i, spineIndex: i,
spineItemId: spineItem.id, spineItemId: spineItem.id,
content: contentText, content: contentText,
startPage: currentPage,
endPage: currentPage + pageSplitResult.totalPages - 1,
scrollHeight,
charCount, charCount,
pagesInChapter: pageSplitResult.totalPages, estimatedPages,
pages: pageSplitResult.pages, });
};
chapters.push(chapterInfo);
currentPage += pageSplitResult.totalPages;
} }
const spineMap = new Map<number, SpineInfo>();
const chapterMap = new Map<number, ChapterPageInfo>(); for (const spine of spines) {
for (const chapter of chapters) { spineMap.set(spine.spineIndex, spine);
chapterMap.set(chapter.spineIndex, chapter);
} }
const totalPages = currentPage - 1;
return { return {
totalPages, totalPages: estimatedTotalPages,
chapters, spines,
chapterMap, spineMap,
calculatedAt: Date.now(), calculatedAt: Date.now(),
settings: pageSettings,
}; };
} }
export function getCurrentPageFromScroll( export function getCurrentPageFromScroll(
pageInfo: PageCalculationResult, pageInfo: PageCalculationResult,
currentSpineIndex: number, currentSpineIndex: number,
scrollPosition: number, scrollPosition: number,
viewportHeight: number, viewportHeight: number,
): number { ): number {
const chapter = pageInfo.chapterMap.get(currentSpineIndex); const spine = pageInfo.spineMap.get(currentSpineIndex);
if (!chapter || chapter.pagesInChapter === 0) { if (!spine) return 1;
return 1; const charsBeforeSpine = pageInfo.spines
} .slice(0, currentSpineIndex)
.reduce((sum, s) => sum + s.charCount, 0);
const viewportHeightAdjusted = viewportHeight - 120; const charsInSpine = spine.charCount;
const positionInChapter = Math.floor(scrollPosition / viewportHeightAdjusted); const charsAtPosition = (scrollPosition / viewportHeight) * charsInSpine;
const totalCharsAtPosition = charsBeforeSpine + charsAtPosition;
return Math.min( const charsPerPage = 2000;
chapter.endPage, return Math.ceil(totalCharsAtPosition / charsPerPage);
Math.max(chapter.startPage, chapter.startPage + positionInChapter),
);
} }
export function getScrollPositionForPage( export function getScrollPositionForPage(
pageInfo: PageCalculationResult, pageInfo: PageCalculationResult,
targetPage: number, targetPage: number,
viewportHeight: number, viewportHeight: number,
): { spineIndex: number; scrollTop: number } | null { ): { spineIndex: number; scrollTop: number } | null {
const viewportHeightAdjusted = viewportHeight - 120; const charsPerPage = 2000;
const targetChar = (targetPage - 1) * charsPerPage;
for (const chapter of pageInfo.chapters) { let charsAccumulated = 0;
if (targetPage >= chapter.startPage && targetPage <= chapter.endPage) { for (const spine of pageInfo.spines) {
const positionInChapter = targetPage - chapter.startPage; charsAccumulated += spine.charCount;
const scrollTop = positionInChapter * viewportHeightAdjusted; if (targetChar < charsAccumulated) {
const charsBefore = charsAccumulated - spine.charCount;
const charsIntoSpine = targetChar - charsBefore;
const scrollTop = (charsIntoSpine / spine.charCount) * viewportHeight;
return { return {
spineIndex: chapter.spineIndex, spineIndex: spine.spineIndex,
scrollTop, scrollTop: Math.max(
0,
Math.min(scrollTop, viewportHeight * spine.estimatedPages),
),
}; };
} }
} }
const lastSpine = pageInfo.spines[pageInfo.spines.length - 1];
return null; return {
spineIndex: lastSpine?.spineIndex || 0,
scrollTop: 0,
};
} }
export function calculateProgressPercentage( export function calculateProgressPercentage(
pageInfo: PageCalculationResult, pageInfo: PageCalculationResult,
currentPage: number, currentPage: number,
+26 -35
View File
@@ -72,15 +72,15 @@ function setViewMode(container: HTMLElement, mode: ViewMode): void {
function applyPaginatedMode(element: HTMLElement): void { function applyPaginatedMode(element: HTMLElement): void {
element.classList.add("paginated"); element.classList.add("paginated");
// Set up for CSS columns
// True pagination: content fits exactly in viewport, no scrolling element.style.height = "calc(100vh - 120px)";
element.style.height = "calc(100vh - 120px)"; // Account for chrome (top 60px + bottom 60px) element.style.overflowY = "auto";
element.style.overflow = "hidden"; element.style.overflowX = "hidden";
element.style.columnCount = "1"; element.style.columnCount = "1";
element.style.columnFill = "auto";
element.style.columnGap = "0"; element.style.columnGap = "0";
element.style.position = "relative"; element.style.position = "relative";
// Inject CSS for CSS column pagination
// Inject CSS for page clipping
injectPaginatedStyles(); injectPaginatedStyles();
} }
@@ -92,23 +92,22 @@ function injectPaginatedStyles(): void {
const style = document.createElement("style"); const style = document.createElement("style");
style.id = "paginated-styles"; style.id = "paginated-styles";
style.textContent = ` style.textContent = `
.paginated .ebook-page { .paginated {
height: 100%; height: calc(100vh - 120px) !important;
overflow: hidden; overflow-y: auto !important;
display: flex; overflow-x: hidden !important;
flex-direction: column; column-fill: auto !important;
column-count: 1 !important;
column-gap: 0 !important;
position: relative !important;
} }
.paginated .ebook-page > * { .paginated > * {
max-height: 100%; max-width: 100%;
overflow: hidden; column-fill: auto;
} }
.paginated .ebook-content { .paginated .ebook-content {
height: 100%; height: auto !important;
overflow: hidden !important; min-height: 100%;
position: relative;
}
.paginated .ebook-content * {
overflow-wrap: break-word;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -144,29 +143,22 @@ function applyDoubleColumn(element: HTMLElement): void {
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;
const totalPages = getTotalPageCount(container);
const pageHeight = content.clientHeight; const targetPage = Math.min(Math.max(1, pageNumber), totalPages);
const scrollTop = (pageNumber - 1) * pageHeight; const viewportHeight = window.innerHeight - 120;
const scrollTop = (targetPage - 1) * viewportHeight;
content.scrollTo({ content.scrollTo({
top: scrollTop, top: scrollTop,
behavior: "smooth", behavior: "smooth",
}); });
const pageInfo = container.querySelector(".page-info");
if (pageInfo) {
pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`;
}
} }
function getTotalPageCount(container: HTMLElement): number { function getTotalPageCount(container: HTMLElement): number {
const content = container.querySelector(".ebook-content") as HTMLElement; const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return 1; if (!content) return 1;
const viewportHeight = window.innerHeight - 120;
const totalHeight = content.scrollHeight; const contentHeight = content.scrollHeight;
const pageHeight = content.clientHeight; return Math.max(1, Math.ceil(contentHeight / viewportHeight));
return Math.ceil(totalHeight / pageHeight);
} }
export function getCurrentViewMode(container: HTMLElement): ViewMode { export function getCurrentViewMode(container: HTMLElement): ViewMode {
@@ -180,4 +172,3 @@ export function getCurrentViewMode(container: HTMLElement): ViewMode {
return "paginated"; return "paginated";
} }