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
This commit is contained in:
2026-04-05 21:14:38 -04:00
parent 4574885dcd
commit 26c0a2d001
2 changed files with 849 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
export interface ChapterPageInfo {
spineIndex: number;
spineItemId: string;
content: string;
startPage: number;
endPage: number;
scrollHeight: number;
charCount: number;
}
export interface PageCalculationResult {
totalPages: number;
chapters: ChapterPageInfo[];
chapterMap: Map<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 = Array.from(doc.querySelectorAll("img"));
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
let blob = resources.get(src);
if (!blob) blob = resources.get(src.split("/").pop() || "");
if (blob) {
const blobUrl = URL.createObjectURL(blob);
img.setAttribute("src", blobUrl);
}
}
container.appendChild(doc.body);
document.body.appendChild(container);
return container;
}
function calculatePageBreaks(
scrollHeight: number,
viewportHeight: number,
): number {
if (scrollHeight <= 0 || viewportHeight <= 0) return 1;
return Math.ceil(scrollHeight / viewportHeight);
}
export async function calculatePagesForEbook(
cif: EbookCIF,
viewportWidth: number,
settings: {
fontSize?: number;
lineHeight?: number;
marginWidth?: number;
},
): Promise<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);
}