Add paragraph index LUT for accurate KOReader position sync

Store per-page paragraph indices in section cache to enable precise
XPath-to-page and page-to-XPath mapping without reparsing XHTML.

Forward path (upload): generates XPath directly from paragraph LUT
instead of byte-offset estimation, eliminating drift in chapters
with non-uniform content density.

Reverse path (download): resolves incoming KOReader XPath p[N] to
the exact page via paragraph LUT lookup.

Paragraph counter counts all <p> elements including display:none
to match ChapterXPathIndexer and crengine's standard XPath counting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jpirnay
2026-03-22 12:29:26 +01:00
co-authored by Claude Opus 4.6
parent dccb82642d
commit b625b8bd26
17 changed files with 354 additions and 43 deletions
@@ -491,6 +491,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
// Track body element depth for paragraph index counting
if (strcmp(name, "body") == 0 && self->xpathBodyDepth < 0) {
self->xpathBodyDepth = self->depth;
}
// Count <p> sibling indices at body-child level. Must happen BEFORE the display:none
// check so that hidden <p> elements are still counted, matching ChapterXPathIndexer's
// counting (pure XML, no CSS). This ensures paragraph indices in the section cache LUT
// align with KOReader's crengine XPath indices.
if (self->xpathBodyDepth >= 0 && self->depth == self->xpathBodyDepth + 1 && strcmp(name, "p") == 0) {
self->xpathParagraphIndex++;
}
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
// start skip
self->skipUntilDepth = self->depth;
@@ -1062,6 +1075,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
@@ -1080,6 +1094,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
}
if (currentPageNextY + lineHeight > viewportHeight) {
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
@@ -75,6 +75,14 @@ class ChapterHtmlSlimParser {
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
// Paragraph index tracking for XPath-to-page lookup table.
// Counts <p> sibling indices (1-based, matching XPath convention) during page building.
// Stored per page in the section cache so that XPath p[N] can be resolved to a page
// without reparsing, and current page can generate an XPath without reparsing.
uint16_t xpathParagraphIndex = 0; // current <p> sibling index (1-based)
int xpathBodyDepth = -1; // depth of the <body> element (-1 = not yet seen)
std::vector<uint16_t> paragraphIndexPerPage; // <p> index at each page completion
// Footnote link tracking
bool insideFootnoteLink = false;
int footnoteLinkDepth = -1;
@@ -126,4 +134,5 @@ class ChapterHtmlSlimParser {
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
const std::vector<uint16_t>& getParagraphIndexPerPage() const { return paragraphIndexPerPage; }
};