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
This commit is contained in:
2026-04-11 00:52:15 -04:00
parent 486fa1313d
commit c8fa4c4a4b
@@ -9,6 +9,71 @@ import type {
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
@@ -183,25 +248,26 @@ export async function calculatePagination(
continue;
}
// Split into pages
// 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: Math.round((range.start / wordCount) * charCount),
charEnd: Math.round((range.end / wordCount) * charCount),
charStart: htmlCharStart,
charEnd: htmlCharEnd,
wordStart: range.start,
wordEnd: range.end,
cfi: generateCFI(
i,
Math.round((range.start / wordCount) * charCount),
charCount,
spineItem.id,
),
cfi: generateCFI(i, htmlCharStart, charCount, spineItem.id),
};
pages.push(page);