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:
@@ -0,0 +1,569 @@
|
||||
# EPUB Dynamic Page Calculator - Phase 2 Implementation
|
||||
|
||||
This document contains the complete implementation for dynamic viewport-based EPUB page calculation.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
web/src/reader/ebook/page-calculator.ts (NEW)
|
||||
web/src/reader/core/reader-state.ts (MODIFY)
|
||||
web/src/reader/core/reader-navigation.ts (MODIFY)
|
||||
web/src/reader/features/progress-indicator.ts (MODIFY)
|
||||
web/src/reader/reader-shell.ts (MODIFY)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. New File: page-calculator.ts
|
||||
|
||||
Create: `web/src/reader/ebook/page-calculator.ts`
|
||||
|
||||
```typescript
|
||||
import type { EbookCIF, SpineItem } from "../../types/reader";
|
||||
|
||||
export interface ChapterPageInfo {
|
||||
spineIndex: number;
|
||||
spineItemId: string;
|
||||
content: string;
|
||||
startPage: number;
|
||||
endPage: number;
|
||||
scrollHeight: number;
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
export interface PageCalculationResult {
|
||||
totalPages: number;
|
||||
chapters: ChapterPageInfo[];
|
||||
chapterMap: Map<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:
|
||||
|
||||
```typescript
|
||||
import type { PageCalculationResult } from "../ebook/page-calculator";
|
||||
```
|
||||
|
||||
Add to the reader state interfaces:
|
||||
|
||||
```typescript
|
||||
interface UniversalReader {
|
||||
type: "ebook";
|
||||
cif: any;
|
||||
currentSpineIndex: number;
|
||||
pageCalculationResult?: PageCalculationResult; // ADD THIS
|
||||
currentScrollPosition?: number; // ADD THIS
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Modify: reader-navigation.ts
|
||||
|
||||
Add scroll tracking and integrate page calculator.
|
||||
|
||||
### Add imports at top of file:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
calculatePagesForEbook,
|
||||
getCurrentPageFromScroll,
|
||||
getScrollPositionForPage,
|
||||
calculateProgressPercentage,
|
||||
type PageCalculationResult,
|
||||
} from "../ebook/page-calculator";
|
||||
```
|
||||
|
||||
### Add state variable after getState() declaration:
|
||||
|
||||
```typescript
|
||||
let pageCalculationResult: PageCalculationResult | null = null;
|
||||
let isCalculatingPages = false;
|
||||
```
|
||||
|
||||
### Add function to initialize page calculation (call after reader is ready):
|
||||
|
||||
```typescript
|
||||
async function initializePageCalculation() {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
if (isCalculatingPages) return;
|
||||
|
||||
isCalculatingPages = true;
|
||||
|
||||
try {
|
||||
const settings = getDefaultSettings();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
pageCalculationResult = await calculatePagesForEbook(
|
||||
state.currentReader.cif,
|
||||
viewportWidth,
|
||||
{
|
||||
fontSize: settings.font_size,
|
||||
lineHeight: settings.line_height,
|
||||
marginWidth: settings.margin_width,
|
||||
}
|
||||
);
|
||||
|
||||
state.currentReader.pageCalculationResult = pageCalculationResult;
|
||||
setState({ currentReader: state.currentReader });
|
||||
|
||||
console.log("Page calculation complete:", pageCalculationResult.totalPages, "pages");
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate pages:", error);
|
||||
} finally {
|
||||
isCalculatingPages = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update renderSpineItem function to reset scroll position when changing chapters:
|
||||
|
||||
After `state.currentReader.currentSpineIndex++` or `--`, add:
|
||||
|
||||
```typescript
|
||||
if (pageCalculationResult) {
|
||||
const chapter = pageCalculationResult.chapterMap.get(state.currentReader.currentSpineIndex);
|
||||
if (chapter) {
|
||||
// Reset scroll to start of new chapter
|
||||
container.scrollTop = 0;
|
||||
state.currentReader.currentScrollPosition = 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add scroll tracking setup in renderSpineItem:
|
||||
|
||||
After `container.innerHTML = ...`, add:
|
||||
|
||||
```typescript
|
||||
container.addEventListener("scroll", () => {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
|
||||
state.currentReader.currentScrollPosition = container.scrollTop;
|
||||
|
||||
if (pageCalculationResult) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const currentPage = getCurrentPageFromScroll(
|
||||
pageCalculationResult,
|
||||
state.currentReader.currentSpineIndex,
|
||||
container.scrollTop,
|
||||
viewportHeight
|
||||
);
|
||||
|
||||
const percentage = calculateProgressPercentage(pageCalculationResult, currentPage);
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage,
|
||||
totalPages: pageCalculationResult.totalPages,
|
||||
percentage,
|
||||
});
|
||||
}
|
||||
}, { passive: true });
|
||||
```
|
||||
|
||||
### Update navigation functions to use page calculation:
|
||||
|
||||
In `nextPage()` and `previousPage()`, after updating state:
|
||||
|
||||
```typescript
|
||||
if (pageCalculationResult) {
|
||||
const container = document.getElementById("reader-content");
|
||||
if (container) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const target = getScrollPositionForPage(
|
||||
pageCalculationResult,
|
||||
state.currentReader.currentPage || state.currentReader.currentSpineIndex + 1,
|
||||
viewportHeight
|
||||
);
|
||||
if (target && target.spineIndex !== state.currentReader.currentSpineIndex) {
|
||||
state.currentReader.currentSpineIndex = target.spineIndex;
|
||||
container.scrollTop = target.scrollTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Modify: progress-indicator.ts
|
||||
|
||||
Update to use dynamic page calculation.
|
||||
|
||||
Find the ebook handling in `updateProgressDisplay()` function and replace:
|
||||
|
||||
```typescript
|
||||
if (state.currentReader.type === "ebook") {
|
||||
const reader = state.currentReader as any;
|
||||
const pageInfo = reader.pageCalculationResult;
|
||||
|
||||
if (pageInfo && pageInfo.totalPages > 0) {
|
||||
const container = document.getElementById("reader-content");
|
||||
const viewportHeight = window.innerHeight;
|
||||
const currentPage = getCurrentPageFromScroll(
|
||||
pageInfo,
|
||||
reader.currentSpineIndex,
|
||||
container?.scrollTop || 0,
|
||||
viewportHeight
|
||||
);
|
||||
currentPage = currentPage;
|
||||
totalPages = pageInfo.totalPages;
|
||||
} else {
|
||||
// Fallback to estimated pages while calculating
|
||||
currentPage = reader.currentSpineIndex + 1;
|
||||
totalPages = state.readerMetadata?.total_pages || state.currentReader.cif.locations?.estimatedPages || state.currentReader.cif.spine.length;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Modify: reader-shell.ts
|
||||
|
||||
Initialize page calculation after reader is ready.
|
||||
|
||||
Find where `renderSpineItem()` is called after `readerEvents.emit("readerReady", ...)`, add:
|
||||
|
||||
```typescript
|
||||
readerEvents.emit("readerReady", currentReader);
|
||||
|
||||
// ADD THIS: Initialize page calculation
|
||||
setTimeout(() => {
|
||||
const { initializePageCalculation } = require("./core/reader-navigation");
|
||||
initializePageCalculation();
|
||||
}, 100);
|
||||
```
|
||||
|
||||
Or if using dynamic import:
|
||||
|
||||
```typescript
|
||||
readerEvents.emit("readerReady", currentReader);
|
||||
|
||||
// ADD THIS: Initialize page calculation after a short delay
|
||||
setTimeout(async () => {
|
||||
const { initializePageCalculation } = await import("./core/reader-navigation");
|
||||
initializePageCalculation();
|
||||
}, 100);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Handle Settings Changes
|
||||
|
||||
When reading settings change (font size, margins), recalculate pages.
|
||||
|
||||
In reader-navigation.ts, add listener in `renderSpineItem` or setup function:
|
||||
|
||||
```typescript
|
||||
context?.events.on("settings:changed", async (detail: { settings: any }) => {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
|
||||
console.log("Recalculating pages due to settings change...");
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
pageCalculationResult = await calculatePagesForEbook(
|
||||
state.currentReader.cif,
|
||||
viewportWidth,
|
||||
{
|
||||
fontSize: detail.settings.font_size,
|
||||
lineHeight: detail.settings.line_height,
|
||||
marginWidth: detail.settings.margin_width,
|
||||
}
|
||||
);
|
||||
|
||||
state.currentReader.pageCalculationResult = pageCalculationResult;
|
||||
setState({ currentReader: state.currentReader });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
1. Load any EPUB - should see page count in progress indicator
|
||||
2. Scroll through content - page number should update in real-time
|
||||
3. Click next/previous chapter - should jump to correct page number
|
||||
4. Change font size in settings - pages should recalculate
|
||||
5. Change margins in settings - pages should recalculate
|
||||
6. Resize browser window - pages should recalculate
|
||||
7. Navigate between different EPUB files - should work correctly each time
|
||||
|
||||
## Console Debug Commands
|
||||
|
||||
```javascript
|
||||
// Check page calculation state
|
||||
window.pageInfo
|
||||
|
||||
// Force recalculation
|
||||
import("./core/reader-navigation").then(m => m.initializePageCalculation())
|
||||
|
||||
// Check current page
|
||||
document.getElementById("reader-content").scrollTop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The page calculation is asynchronous and may take a moment for large books
|
||||
- A loading state can be shown while calculating
|
||||
- The calculation uses a hidden container that is immediately removed after measurement
|
||||
- Images are converted to blob URLs for measurement to get accurate heights
|
||||
- The viewport height is reduced by 120px to account for top/bottom chrome bars
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user