Files
bookhoard/web/src/reader/formats/reflowable/page-calculator.ts
T
john-okeefe c8fa4c4a4b fix: Improve pagination accuracy with HTML-aware character mapping
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
2026-04-11 00:52:15 -04:00

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,
};
}