CRITICAL BUG FIX: The previous pagination system used a linear mapping between word positions and character positions, which is incorrect for HTML content. This caused page boundaries to cut through HTML tags, resulting in: - Missing content (e.g., headings like "Letter 1" skipped entirely) - Truncated text starting mid-sentence - Incorrect page breaks that didn't respect HTML structure Example of the problem: Plain text: "Letter 1\nTo Mrs. Saville" (25 chars, 5 words) HTML: "<p>Letter 1</p><p>To Mrs. Saville" (85 chars) Old calculation: (3 / 5) * 85 = 51 chars (wrong - cuts in middle of tag) Correct mapping: ~45 chars (respects HTML structure) SOLUTION: - Add buildTextNodeMapping() function to traverse HTML DOM - Track character positions for both HTML source and plain text - Create mapWordToHtmlChar() to accurately map word positions to HTML positions - Account for HTML tags, attributes, and element boundaries TECHNICAL DETAILS: - Introduce TextNodeInfo interface to track node positions - Recursively traverse DOM to build accurate character position mapping - Calculate word positions within each text node separately - Map word ranges to precise HTML character positions IMPACT: ✅ Content renders correctly without truncation ✅ Page boundaries respect HTML structure ✅ All text and headings display in correct order ✅ Character positions accurately reflect HTML content This fix resolves the core issue where pagination was calculated based on plain text word positions but applied to HTML source, causing systematic content loss and incorrect page breaks. Related to: EPUB pagination, content rendering accuracy
565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
// Import types
|
|
import { UniversalReader } from "../../reader-shell";
|
|
import type {
|
|
SpineItem,
|
|
SpineInfo,
|
|
PageBoundary,
|
|
PaginationData,
|
|
PaginationSettings,
|
|
ReadingPosition,
|
|
} from "./types";
|
|
|
|
interface TextNodeInfo {
|
|
node: Text;
|
|
startChar: number; // Character position in HTML
|
|
endChar: number; // Character position in HTML
|
|
textStart: number; // Word position in plain text
|
|
textEnd: number; // Word position in plain text
|
|
}
|
|
function buildTextNodeMapping(html: string): TextNodeInfo[] {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const textNodes: TextNodeInfo[] = [];
|
|
|
|
let htmlCharPos = 0;
|
|
let textWordPos = 0;
|
|
|
|
function traverse(node: Node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent || "";
|
|
const words = countWords(text);
|
|
|
|
textNodes.push({
|
|
node: node as Text,
|
|
startChar: htmlCharPos,
|
|
endChar: htmlCharPos + text.length,
|
|
textStart: textWordPos,
|
|
textEnd: textWordPos + words,
|
|
});
|
|
|
|
textWordPos += words;
|
|
htmlCharPos += text.length;
|
|
} else {
|
|
// For element nodes, just count the opening tag length
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const outerHTML = (node as Element).outerHTML;
|
|
const tagEnd = outerHTML.indexOf(">") + 1;
|
|
htmlCharPos += tagEnd;
|
|
|
|
// Recurse into children
|
|
node.childNodes.forEach(traverse);
|
|
|
|
// Count closing tag
|
|
const tagName = (node as Element).tagName;
|
|
htmlCharPos += `</${tagName}>`.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
doc.body.childNodes.forEach(traverse);
|
|
return textNodes;
|
|
}
|
|
|
|
function mapWordToHtmlChar(mapping: TextNodeInfo[], wordPos: number): number {
|
|
for (const info of mapping) {
|
|
if (wordPos >= info.textStart && wordPos <= info.textEnd) {
|
|
// Word is in this text node
|
|
const ratio =
|
|
(wordPos - info.textStart) / (info.textEnd - info.textStart);
|
|
return Math.round(
|
|
info.startChar + ratio * (info.endChar - info.startChar),
|
|
);
|
|
}
|
|
}
|
|
return 0; // Fallback
|
|
}
|
|
|
|
// Constants for word count estimation (from Kavita)
|
|
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
|
|
|
|
// Calculate words per page based on settings
|
|
function calculateWordsPerPage(settings: PaginationSettings): number {
|
|
const fontSizeFactor = 16 / settings.fontSize;
|
|
const lineHeightFactor = 1.6 / settings.lineHeight;
|
|
const areaFactor =
|
|
(settings.viewportWidth * settings.viewportHeight) / (800 * 600);
|
|
|
|
return Math.round(
|
|
WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor,
|
|
);
|
|
}
|
|
|
|
// Extract plain text from HTML
|
|
function extractTextFromHTML(html: string): string {
|
|
// Remove script and style tags
|
|
const withoutScripts = html.replace(
|
|
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
|
"",
|
|
);
|
|
const withoutStyles = withoutScripts.replace(
|
|
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
|
|
"",
|
|
);
|
|
|
|
// Extract text content (simple version, no DOM)
|
|
return withoutStyles
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
// Count words in text
|
|
function countWords(text: string): number {
|
|
return text
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter((w) => w.length > 0).length;
|
|
}
|
|
|
|
// Split text into word ranges for pages
|
|
function splitIntoWordRanges(
|
|
wordCount: number,
|
|
wordsPerPage: number,
|
|
): Array<{ start: number; end: number }> {
|
|
const ranges: Array<{ start: number; end: number }> = [];
|
|
let start = 0;
|
|
|
|
while (start < wordCount) {
|
|
const end = Math.min(start + wordsPerPage, wordCount);
|
|
ranges.push({ start, end });
|
|
start = end;
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
// Escape special characters in CFI
|
|
function escapeCFIString(str: string): string {
|
|
return str
|
|
.replace(/\[/g, "\\[")
|
|
.replace(/\]/g, "\\]")
|
|
.replace(/\(/g, "\\(")
|
|
.replace(/\)/g, "\\)")
|
|
.replace(/,/g, "\\,")
|
|
.replace(/;/g, "\\;")
|
|
.replace(/=/g, "\\=");
|
|
}
|
|
|
|
// Generate EPUB CFI for a position in spine
|
|
// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/
|
|
// Format: epubcfi(/6/spine_index!/path/element/offset)
|
|
function generateCFI(
|
|
spineIndex: number,
|
|
charOffset: number,
|
|
totalChars: number,
|
|
spineItemId: string,
|
|
): string {
|
|
const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : "";
|
|
const offset = Math.min(charOffset, totalChars);
|
|
const spinePath = `/6/${spineIndex + 2}${escapedId}`;
|
|
|
|
return `epubcfi(${spinePath}!/4/2/1:${offset})`;
|
|
}
|
|
|
|
// Parse EPUB CFI to extract position
|
|
function parseCFI(
|
|
cfi: string,
|
|
): { spineIndex: number; charOffset: number } | null {
|
|
if (!cfi.startsWith("epubcfi(")) {
|
|
return null;
|
|
}
|
|
|
|
// Remove epubcfi( wrapper
|
|
const inner = cfi.slice(8, -1);
|
|
if (!inner) return null;
|
|
|
|
// Split on ! to separate spine path from content path
|
|
const parts = inner.split("!");
|
|
if (parts.length < 2) return null;
|
|
|
|
// Extract spine index from /6/4 or /6/4[id] format
|
|
const spineMatch = parts[0].match(/\/6\/(\d+)/);
|
|
if (!spineMatch) return null;
|
|
|
|
const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset
|
|
if (spineIndex < 0) return null;
|
|
|
|
// Extract character offset from :123 format
|
|
const offsetMatch = parts[1].match(/:(\d+)$/);
|
|
if (!offsetMatch) return null;
|
|
|
|
const charOffset = parseInt(offsetMatch[1]);
|
|
|
|
return { spineIndex, charOffset };
|
|
}
|
|
|
|
// Calculate pagination for entire book
|
|
export async function calculatePagination(
|
|
spineItems: SpineItem[],
|
|
contentMap: Map<string, Blob>,
|
|
settings: PaginationSettings,
|
|
): Promise<PaginationData> {
|
|
const wordsPerPage = calculateWordsPerPage(settings);
|
|
const spines: SpineInfo[] = [];
|
|
const pageMap = new Map<number, PageBoundary>();
|
|
let globalPageIndex = 0;
|
|
|
|
// Process each spine item
|
|
for (let i = 0; i < spineItems.length; i++) {
|
|
const spineItem = spineItems[i];
|
|
|
|
// Skip non-HTML items (cover pages, etc)
|
|
if (spineItem.type !== "html") {
|
|
spines.push({
|
|
spineIndex: i,
|
|
spineItemId: spineItem.id,
|
|
content: "",
|
|
charCount: 0,
|
|
wordCount: 0,
|
|
cfiStart: "",
|
|
pages: [],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Get content
|
|
const contentBlob = contentMap.get(spineItem.content);
|
|
if (!contentBlob) {
|
|
console.warn(`Content not found for spine ${spineItem.id}`);
|
|
continue;
|
|
}
|
|
|
|
const contentHTML = await contentBlob.text();
|
|
const plainText = extractTextFromHTML(contentHTML);
|
|
const wordCount = countWords(plainText);
|
|
const charCount = plainText.length;
|
|
|
|
// Skip empty spines
|
|
if (wordCount === 0) {
|
|
spines.push({
|
|
spineIndex: i,
|
|
spineItemId: spineItem.id,
|
|
content: contentHTML,
|
|
charCount,
|
|
wordCount,
|
|
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
|
pages: [],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Split into pages based on TEXT content, not HTML
|
|
const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage);
|
|
const pages: PageBoundary[] = [];
|
|
// Build a map of word positions to HTML character positions
|
|
const textNodeInfo = buildTextNodeMapping(contentHTML);
|
|
for (let j = 0; j < wordRanges.length; j++) {
|
|
const range = wordRanges[j];
|
|
|
|
// Map word positions to ACTUAL HTML character positions
|
|
const htmlCharStart = mapWordToHtmlChar(textNodeInfo, range.start);
|
|
const htmlCharEnd = mapWordToHtmlChar(textNodeInfo, range.end);
|
|
|
|
const page: PageBoundary = {
|
|
pageIndex: globalPageIndex,
|
|
localPageIndex: j,
|
|
charStart: htmlCharStart,
|
|
charEnd: htmlCharEnd,
|
|
wordStart: range.start,
|
|
wordEnd: range.end,
|
|
cfi: generateCFI(i, htmlCharStart, charCount, spineItem.id),
|
|
};
|
|
|
|
pages.push(page);
|
|
pageMap.set(globalPageIndex, page);
|
|
globalPageIndex++;
|
|
}
|
|
|
|
spines.push({
|
|
spineIndex: i,
|
|
spineItemId: spineItem.id,
|
|
content: contentHTML,
|
|
charCount,
|
|
wordCount,
|
|
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
|
pages,
|
|
});
|
|
}
|
|
|
|
// Build map
|
|
const spineMap = new Map<number, SpineInfo>();
|
|
for (const spine of spines) {
|
|
spineMap.set(spine.spineIndex, spine);
|
|
}
|
|
|
|
return {
|
|
totalPages: globalPageIndex,
|
|
spines,
|
|
spineMap,
|
|
pageMap,
|
|
calculatedAt: Date.now(),
|
|
settings: { ...settings, wordsPerPage },
|
|
};
|
|
}
|
|
|
|
// Find which page contains a CFI
|
|
export function findPageByCFI(
|
|
pagination: PaginationData,
|
|
targetCFI: string,
|
|
): number {
|
|
const parsed = parseCFI(targetCFI);
|
|
if (!parsed) return 1;
|
|
|
|
const { spineIndex, charOffset } = parsed;
|
|
const spine = pagination.spineMap.get(spineIndex);
|
|
|
|
if (!spine || spine.pages.length === 0) return 1;
|
|
|
|
// Find page containing this character offset
|
|
for (const page of spine.pages) {
|
|
if (charOffset >= page.charStart && charOffset < page.charEnd) {
|
|
return page.pageIndex + 1; // 1-indexed
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
// Extract HTML slice between character offsets
|
|
function extractHTMLSlice(
|
|
html: string,
|
|
charStart: number,
|
|
charEnd: number,
|
|
): string {
|
|
if (charStart === 0 && charEnd >= html.length) {
|
|
return html;
|
|
}
|
|
|
|
// Parse HTML and extract text nodes within the character range
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const body = doc.body;
|
|
|
|
// Find all text nodes and their cumulative character counts
|
|
type TextNodeInfo = { node: Text; startChar: number; endChar: number };
|
|
const textNodes: TextNodeInfo[] = [];
|
|
let cumulativeChars = 0;
|
|
|
|
function traverse(node: Node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent || "";
|
|
const startChar = cumulativeChars;
|
|
cumulativeChars += text.length;
|
|
const endChar = cumulativeChars;
|
|
|
|
textNodes.push({ node: node as Text, startChar, endChar });
|
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
// Skip script and style tags
|
|
if (node instanceof HTMLElement) {
|
|
const tagName = node.tagName.toLowerCase();
|
|
if (tagName === "script" || tagName === "style") {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Recursively traverse children
|
|
for (const child of Array.from(node.childNodes)) {
|
|
traverse(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
traverse(body);
|
|
|
|
// Find which text nodes intersect with the requested range
|
|
const relevantNodes: { node: Text; before: string; after: string }[] = [];
|
|
|
|
for (const { node, startChar, endChar } of textNodes) {
|
|
if (endChar <= charStart || startChar >= charEnd) {
|
|
// No overlap
|
|
continue;
|
|
}
|
|
const text = node.textContent || "";
|
|
let resultText = text;
|
|
// Trim from left if node starts before page
|
|
if (startChar < charStart) {
|
|
resultText = text.substring(charStart - startChar);
|
|
}
|
|
// Trim from right if node extends past page end
|
|
if (endChar > charEnd) {
|
|
// Calculate where to cut within the (potentially already trimmed) text
|
|
const cutPosition = charEnd - startChar;
|
|
resultText = text.substring(0, cutPosition);
|
|
}
|
|
// Handle case where both trims are needed
|
|
if (startChar < charStart && endChar > charEnd) {
|
|
const leftTrim = charStart - startChar;
|
|
const rightTrim = endChar - charEnd;
|
|
resultText = text.substring(leftTrim, text.length - rightTrim);
|
|
}
|
|
relevantNodes.push({ node, before: "", after: resultText });
|
|
}
|
|
|
|
// Preserve original HTML structure for nodes in range
|
|
const startNode = textNodes.find((n) => n.endChar > charStart);
|
|
const endNode = textNodes.find((n) => n.startChar < charEnd);
|
|
|
|
if (!startNode || !endNode) {
|
|
return html;
|
|
}
|
|
|
|
// Find element boundaries
|
|
let startElement: Node | null = startNode.node;
|
|
while (startElement && startElement.parentNode !== body) {
|
|
startElement = startElement.parentNode;
|
|
}
|
|
|
|
let endElement: Node | null = endNode.node;
|
|
while (endElement && endElement.parentNode !== body) {
|
|
endElement = endElement.parentNode;
|
|
}
|
|
|
|
// Extract and modify the relevant portion
|
|
if (startElement && endElement) {
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
let currentElement: Node | null = startElement;
|
|
let foundEnd = false;
|
|
|
|
while (currentElement && !foundEnd) {
|
|
if (currentElement.nodeType === Node.ELEMENT_NODE) {
|
|
const clone = (currentElement as Element).cloneNode(false);
|
|
fragment.appendChild(clone);
|
|
|
|
// Process children
|
|
for (const child of Array.from(currentElement.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
const textNodeInfo = textNodes.find((n) => n.node === child);
|
|
if (textNodeInfo) {
|
|
const modified = document.createTextNode(
|
|
relevantNodes.find((n) => n.node === child)?.after || "",
|
|
);
|
|
clone.appendChild(modified);
|
|
}
|
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
// Recursively handle element children
|
|
const childClone = child.cloneNode(true);
|
|
clone.appendChild(childClone);
|
|
}
|
|
}
|
|
|
|
if (currentElement === endElement) {
|
|
foundEnd = true;
|
|
}
|
|
}
|
|
|
|
currentElement = currentElement.nextSibling;
|
|
}
|
|
|
|
// Serialize fragment back to HTML
|
|
const tempDiv = document.createElement("div");
|
|
tempDiv.appendChild(fragment);
|
|
return tempDiv.innerHTML;
|
|
}
|
|
|
|
// Fallback: return original HTML if extraction fails
|
|
return html;
|
|
}
|
|
|
|
// Get page content (HTML slice for a page)
|
|
export function getPageContent(
|
|
pagination: PaginationData,
|
|
pageIndex: number,
|
|
): string {
|
|
const page = pagination.pageMap.get(pageIndex);
|
|
if (!page) return "";
|
|
|
|
// Find the spine that contains this page
|
|
// Pages are stored in order, so we can find the spine by checking which pages it contains
|
|
let spine: SpineInfo | undefined;
|
|
for (const s of pagination.spines) {
|
|
if (s.pages.some((p) => p.pageIndex === pageIndex)) {
|
|
spine = s;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!spine) return "";
|
|
|
|
// Extract HTML content between page boundaries
|
|
const htmlSlice = extractHTMLSlice(
|
|
spine.content,
|
|
page.charStart,
|
|
page.charEnd,
|
|
);
|
|
|
|
// Wrap in a div to ensure valid HTML structure
|
|
return `<div class="page-content-wrapper">${htmlSlice}</div>`;
|
|
}
|
|
|
|
// Recalculate pagination on viewport change
|
|
export function shouldRecalculate(
|
|
pagination: PaginationData | null,
|
|
newSettings: PaginationSettings,
|
|
): boolean {
|
|
if (!pagination) return true;
|
|
|
|
const sizeChanged =
|
|
Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) >
|
|
50 ||
|
|
Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) >
|
|
50;
|
|
|
|
const fontChanged = pagination.settings.fontSize !== newSettings.fontSize;
|
|
const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight;
|
|
|
|
return sizeChanged || fontChanged || lineChanged;
|
|
}
|
|
|
|
// Create position object from page number
|
|
export function createPositionFromPage(
|
|
book: UniversalReader,
|
|
page: number,
|
|
): ReadingPosition {
|
|
if (!book.pagination) {
|
|
return {
|
|
currentPage: 1,
|
|
spineIndex: 0,
|
|
localPageIndex: 0,
|
|
cfi: "",
|
|
progress: 0,
|
|
};
|
|
}
|
|
|
|
const pageIndex = page - 1;
|
|
const pageData = book.pagination.pageMap.get(pageIndex);
|
|
|
|
if (!pageData) {
|
|
return {
|
|
currentPage: 1,
|
|
spineIndex: 0,
|
|
localPageIndex: 0,
|
|
cfi: "",
|
|
progress: 0,
|
|
};
|
|
}
|
|
|
|
// Find which spine this page belongs to
|
|
let spineIndex = 0;
|
|
for (const spine of book.pagination.spines) {
|
|
if (pageData.localPageIndex < spine.pages.length) {
|
|
spineIndex = spine.spineIndex;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
currentPage: page,
|
|
spineIndex,
|
|
localPageIndex: pageData.localPageIndex,
|
|
cfi: pageData.cfi,
|
|
progress:
|
|
book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0,
|
|
};
|
|
}
|