#include "ProgressMapper.h" #include #include #include #include "ChapterXPathIndexer.h" namespace { bool resolveFromPercentage(const std::shared_ptr& epub, const float percentage, const int spineCount, int& outSpineIndex, float& outIntraSpineProgress) { if (!std::isfinite(percentage) || !epub || spineCount <= 0) { return false; } const size_t bookSize = epub->getBookSize(); if (bookSize == 0) { return false; } const float sanitizedPercentage = std::clamp(percentage, 0.0f, 1.0f); const size_t targetBytes = static_cast(bookSize * sanitizedPercentage); outSpineIndex = spineCount - 1; for (int i = 0; i < spineCount; i++) { const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i); if (cumulativeSize >= targetBytes) { outSpineIndex = i; break; } } outIntraSpineProgress = 0.0f; const size_t prevCumSize = (outSpineIndex > 0) ? epub->getCumulativeSpineItemSize(outSpineIndex - 1) : 0; const size_t currentCumSize = epub->getCumulativeSpineItemSize(outSpineIndex); const size_t spineSize = currentCumSize - prevCumSize; if (spineSize > 0) { const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0; outIntraSpineProgress = static_cast(bytesIntoSpine) / static_cast(spineSize); outIntraSpineProgress = std::clamp(outIntraSpineProgress, 0.0f, 1.0f); } return true; } // Compute intra-spine progress from KOReader's book percentage, assuming the target // spine is known. This is the constrained version of resolveFromPercentage that // honors an XPath-derived spine index even when the heavy XPath resolver couldn't // run (typically because heap was too fragmented to inflate the chapter at sync time). // // The math is identical to the per-spine portion of resolveFromPercentage. Returns 0 // when the percentage maps to bytes before the spine's start (the position lives // inside the spine by assumption, so clamp to 0) and 1 when it overshoots the end. float intraSpineFromPercentage(const std::shared_ptr& epub, const int spineIndex, const float percentage) { if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount() || !std::isfinite(percentage)) { return 0.0f; } const size_t bookSize = epub->getBookSize(); if (bookSize == 0) { return 0.0f; } const float sanitized = std::clamp(percentage, 0.0f, 1.0f); const size_t targetBytes = static_cast(bookSize * sanitized); const size_t prevCumSize = (spineIndex > 0) ? epub->getCumulativeSpineItemSize(spineIndex - 1) : 0; const size_t currentCumSize = epub->getCumulativeSpineItemSize(spineIndex); const size_t spineSize = currentCumSize - prevCumSize; if (spineSize == 0) { return 0.0f; } if (targetBytes <= prevCumSize) { return 0.0f; } const size_t bytesIntoSpine = targetBytes - prevCumSize; return std::clamp(static_cast(bytesIntoSpine) / static_cast(spineSize), 0.0f, 1.0f); } // KOReader emits chapter-start XPaths as ".../body/.0" or just // ".../body/text()[1].0" — there's no paragraph segment, and the character offset is 0. // These unambiguously denote "the start of the spine"; we can pin intra=0 without // inflating the chapter. Catches the common case of starting a new chapter on // another device, which previously round-tripped through book-percentage byte math // and landed several pages into the chapter due to byte-vs-page-density skew. bool isChapterStartXPath(const std::string& xpath) { // Reject anything with a paragraph or list-item predicate — those carry real // position information that can't be flattened to "start of spine". if (xpath.find("/p[") != std::string::npos) return false; if (xpath.find("/li[") != std::string::npos) return false; // The path must end with a ".0" text-point segment. The reverse mapper already // strips text() suffixes for matching, but here we look at the raw form: either // ".0" (cursor at start of element) or "text()[1].0" / similar (cursor at // start of the first text node) with no following character offset. const size_t dotPos = xpath.rfind('.'); if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return false; for (size_t i = dotPos + 1; i < xpath.size(); i++) { if (xpath[i] != '0') return false; } return true; } } // namespace KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& epub, const CrossPointPosition& pos) { KOReaderPosition result; // Calculate page progress within current spine item. // Page numbers are 0-based and totalPages is 1-based, so the last page is // (totalPages - 1). Dividing by (totalPages - 1) maps page 0 to intra=0 and the // last page to intra=1, which is what KOReader expects when round-tripping. // Dividing by totalPages would peg the last page short of 100%, so a user who // finished the chapter would only show ~94% on KOReader. float intraSpineProgress = 0.0f; if (pos.totalPages > 1) { intraSpineProgress = static_cast(pos.pageNumber) / static_cast(pos.totalPages - 1); } else if (pos.totalPages == 1) { intraSpineProgress = 0.0f; } // Calculate overall book progress (0.0-1.0) result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress); // Generate XPath for the current position via byte-offset scan. Targeting the // paragraph LUT entry instead would snap to the start of the paragraph the user // is inside, which causes pulled positions to land at the start of the chapter // when an opening paragraph spans many pages. result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress); if (result.xpath.empty()) { result.xpath = generateXPath(pos.spineIndex); } // Get chapter info for logging const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex); const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown"; LOG_DBG("ProgressMapper", "CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s", chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str()); return result; } CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr& epub, const KOReaderPosition& koPos, int currentSpineIndex, int totalPagesInCurrentSpine) { CrossPointPosition result; result.spineIndex = 0; result.pageNumber = 0; result.totalPages = 0; if (!epub || epub->getSpineItemsCount() <= 0) { return result; } const int spineCount = epub->getSpineItemsCount(); float resolvedIntraSpineProgress = -1.0f; bool xpathExactMatch = false; bool usedXPathMapping = false; bool usedPercentageReconcile = false; // Mapping source used for the final log line; updated as we narrow down the path // actually taken (xpath / xpath+percentage / xpath-spine+percentage / percentage). const char* mappingSource = "percentage"; int xpathSpineIndex = -1; const bool haveXPathSpine = ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 && xpathSpineIndex < spineCount; if (haveXPathSpine) { float intraFromXPath = 0.0f; uint16_t liIndexFromXPath = 0; if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath, xpathExactMatch, &liIndexFromXPath)) { result.spineIndex = xpathSpineIndex; resolvedIntraSpineProgress = intraFromXPath; usedXPathMapping = true; if (liIndexFromXPath > 0) { result.listItemIndex = liIndexFromXPath; result.hasListItemIndex = true; } // KOReader's text-node indexing can differ across renderers/parsers in some // XHTML shapes. When an XPath-resolved position disagrees materially with // KOReader's percentage but points to the same spine, use percentage-derived // intra-spine progress as a safer tie-breaker. if (std::isfinite(koPos.percentage) && resolvedIntraSpineProgress >= 0.0f) { const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f); const float mappedPercentage = epub->calculateProgress(result.spineIndex, resolvedIntraSpineProgress); const float delta = std::fabs(mappedPercentage - sanitizedPercentage); constexpr float kReconcileThreshold = 0.01f; // 1% absolute book progress if (delta > kReconcileThreshold) { int percentageSpineIndex = -1; float percentageIntraSpine = -1.0f; if (resolveFromPercentage(epub, koPos.percentage, spineCount, percentageSpineIndex, percentageIntraSpine) && percentageSpineIndex == result.spineIndex && percentageIntraSpine >= 0.0f) { LOG_DBG("ProgressMapper", "Reconciling XPath position with percentage: spine=%d xpath=%.3f pct=%.3f delta=%.3f -> %.3f", result.spineIndex, resolvedIntraSpineProgress, sanitizedPercentage, delta, percentageIntraSpine); resolvedIntraSpineProgress = percentageIntraSpine; usedPercentageReconcile = true; } } } mappingSource = usedPercentageReconcile ? "xpath+percentage" : "xpath"; } // Extract paragraph index from XPath for direct page lookup via section cache. // Done regardless of whether the heavy XPath resolver succeeded — the paragraph // LUT lookup later (in EpubReaderActivity::NavigationTarget::resolveInto) snaps // to the precise page, so even without intra resolution we get an exact landing. uint16_t pIndex = 0; if (ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(koPos.xpath, pIndex)) { result.paragraphIndex = pIndex; result.hasParagraphIndex = true; } } if (!usedXPathMapping) { // Heavy XPath resolution failed (typically because heap was too fragmented to // inflate the spine at sync time). Salvage as much as we can: // 1) Trust the spine index extracted from the XPath itself — it's purely // string-derived and always correct when present. Using it preserves // cross-chapter syncs even when chapter content can't be re-parsed. // 2) For chapter-start XPaths (ending in ".0" with no paragraph predicate), // pin intra=0. KOReader's percentage carries small per-DOM rounding that // would otherwise leak into a spurious intra > 0 via byte-fraction math. // 3) Otherwise compute intra-spine from KOReader's percentage relative to // the XPath-derived spine. Falls back to global percentage spine selection // only when no XPath spine is available. if (haveXPathSpine) { result.spineIndex = xpathSpineIndex; if (isChapterStartXPath(koPos.xpath)) { resolvedIntraSpineProgress = 0.0f; mappingSource = "xpath-spine+chapter-start"; LOG_DBG("ProgressMapper", "Chapter-start XPath '%s' on spine=%d, pinning intra=0", koPos.xpath.c_str(), xpathSpineIndex); } else { resolvedIntraSpineProgress = intraSpineFromPercentage(epub, xpathSpineIndex, koPos.percentage); mappingSource = "xpath-spine+percentage"; LOG_DBG("ProgressMapper", "XPath resolve unavailable for spine=%d; intra from pct=%.3f -> %.3f", xpathSpineIndex, koPos.percentage, resolvedIntraSpineProgress); } } else { int percentageSpineIndex = -1; float percentageIntraSpine = -1.0f; if (!resolveFromPercentage(epub, koPos.percentage, spineCount, percentageSpineIndex, percentageIntraSpine)) { return result; } result.spineIndex = percentageSpineIndex; resolvedIntraSpineProgress = percentageIntraSpine; } } // Estimate page number within the selected spine item if (result.spineIndex < epub->getSpineItemsCount()) { const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex); const size_t spineSize = currentCumSize - prevCumSize; int estimatedTotalPages = 0; // If we are in the same spine, use the known total pages if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) { estimatedTotalPages = totalPagesInCurrentSpine; } // Otherwise try to estimate based on density from current spine else if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount() && totalPagesInCurrentSpine > 0) { const size_t prevCurrCumSize = (currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; const size_t currCumSize = epub->getCumulativeSpineItemSize(currentSpineIndex); const size_t currSpineSize = currCumSize - prevCurrCumSize; if (currSpineSize > 0) { float ratio = static_cast(spineSize) / static_cast(currSpineSize); estimatedTotalPages = static_cast(totalPagesInCurrentSpine * ratio); if (estimatedTotalPages < 1) estimatedTotalPages = 1; } } result.totalPages = estimatedTotalPages; if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) { const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress)); // Symmetric inverse of the toKOReader formula: intra=1.0 should land on the // last page (totalPages - 1), intra=0 on page 0. Round-to-nearest avoids // truncating mid-page progress down to the previous page. if (estimatedTotalPages > 1) { result.pageNumber = static_cast(clampedProgress * static_cast(estimatedTotalPages - 1) + 0.5f); } else { result.pageNumber = 0; } result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1)); } else if (spineSize > 0 && estimatedTotalPages > 0) { result.pageNumber = 0; } } LOG_DBG("ProgressMapper", "Resolved KOReader position: spine=%d intra=%.3f hasPIdx=%s pIdx=%u hasLiIdx=%s liIdx=%u", result.spineIndex, resolvedIntraSpineProgress, result.hasParagraphIndex ? "yes" : "no", result.paragraphIndex, result.hasListItemIndex ? "yes" : "no", result.listItemIndex); LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)", koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber, mappingSource, xpathExactMatch ? "yes" : "no"); return result; } std::string ProgressMapper::generateXPath(int spineIndex) { // Fallback path when element-level XPath extraction is unavailable. // KOReader uses 1-based XPath predicates; spineIndex is 0-based internally. return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; }