diff --git a/web/src/reader/formats/reflowable/page-calculator.ts b/web/src/reader/formats/reflowable/page-calculator.ts index 687751d..997604b 100644 --- a/web/src/reader/formats/reflowable/page-calculator.ts +++ b/web/src/reader/formats/reflowable/page-calculator.ts @@ -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 += ``.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);