Merge branch 'feat-koysnc-xpath' of https://github.com/jpirnay/crosspoint-reader into mybuild

This commit is contained in:
jpirnay
2026-03-08 09:58:54 +01:00
6 changed files with 771 additions and 41 deletions
+17
View File
@@ -125,6 +125,23 @@ Notes:
- rendering favors reusing precomputed layout data to keep page turns responsive on constrained hardware
- progress/session state is persisted so the reader can reopen at the last position after reboot/sleep
## KOReader sync position mapping
KOReader sync integration is implemented under `lib/KOReaderSync/` and is used by
`src/activities/reader/KOReaderSyncActivity.*`.
Position translation currently follows a dual-path strategy:
- CrossPoint -> KOReader: prefer element-level XPath extracted from the current
spine XHTML; fallback to chapter-level `DocFragment` path when needed.
- KOReader -> CrossPoint: prefer incoming XPath resolution; fallback to
percentage-based estimation if XPath is invalid or cannot be resolved.
Detailed algorithm and constraints (including low-memory rationale for ESP32-C3)
are documented in:
- [KOReader Sync XPath Mapping](koreader-sync-xpath-mapping.md)
## State and persistence
Two singletons are central:
@@ -0,0 +1,99 @@
# KOReader Sync XPath Mapping
This note documents how CrossPoint maps reading positions to and from KOReader sync payloads.
## Problem
CrossPoint internally stores position as:
- `spineIndex` (chapter index, 0-based)
- `pageNumber` + `totalPages`
KOReader sync payload stores:
- `progress` (XPath-like location)
- `percentage` (overall progress)
A direct 1:1 mapping is not guaranteed because page layout differs between engines/devices.
## DocFragment Index Convention
KOReader uses **1-based** XPath predicates throughout, following standard XPath conventions.
The first EPUB spine item is `DocFragment[1]`, the second is `DocFragment[2]`, and so on.
CrossPoint stores spine items as 0-based indices internally. The conversion is:
- **Generating XPath (to KOReader):** `DocFragment[spineIndex + 1]`
- **Parsing XPath (from KOReader):** `spineIndex = DocFragment[N] - 1`
Reference: [koreader/koreader#11585](https://github.com/koreader/koreader/issues/11585) confirms this
via a KOReader contributor mapping spine items to DocFragment numbers.
## Current Strategy
### CrossPoint -> KOReader
Implemented in `ProgressMapper::toKOReader`.
1. Compute overall `percentage` from chapter/page.
2. Attempt to compute a real element-level XPath via `ChapterXPathIndexer::findXPathForProgress`.
3. If XPath extraction fails, fallback to synthetic chapter path:
- `/body/DocFragment[spineIndex + 1]/body`
### KOReader -> CrossPoint
Implemented in `ProgressMapper::toCrossPoint`.
1. Attempt to parse `DocFragment[N]` from incoming XPath; convert N to 0-based `spineIndex = N - 1`.
2. If valid, attempt XPath-to-offset mapping via `ChapterXPathIndexer::findProgressForXPath`.
3. Convert resolved intra-spine progress to page estimate.
4. If XPath path is invalid/unresolvable, fallback to percentage-based chapter/page estimation.
## ChapterXPathIndexer Design
The module reparses **one spine XHTML** on demand using Expat and builds temporary anchors:
Source-of-truth note: XPath anchors are built from the original EPUB spine XHTML bytes (zip item contents), not from CrossPoint's distilled section render cache. This is intentional to preserve KOReader XPath compatibility.
- anchor: `<xpath, textOffset>`
- `textOffset` counts non-whitespace bytes
- When multiple anchors exist for the same path, the one with the **smallest** textOffset is used
(start of element), not the latest periodic anchor.
Forward lookup (CrossPoint → XPath): uses `upper_bound` to find the last anchor at or before the
target text offset, ensuring the returned XPath corresponds to the element the user is currently
inside rather than the next element.
Matching for reverse lookup:
1. exact path match — reported as `exact=yes`
2. index-insensitive path match (`div[2]` vs `div[3]` tolerated) — reported as `exact=no`
3. ancestor fallback — reported as `exact=no`
If no match is found, caller must fallback to percentage.
## Memory / Safety Constraints (ESP32-C3)
The implementation intentionally avoids full DOM storage.
- Parse one chapter only.
- Keep anchors in transient vectors only for duration of call.
- Free XML parser and chapter byte buffer on all success/failure paths.
- No persistent cache structures are introduced by this module.
## Known Limitations
- Page number on reverse mapping is still an estimate (renderer differences).
- XPath mapping intentionally uses original spine XHTML while pagination comes from distilled renderer output, so minor roundtrip page drift is expected.
- Image-only/low-text chapters may yield coarse anchors.
- Extremely malformed XHTML can force fallback behavior.
## Operational Logging
`ProgressMapper` logs mapping source in reverse direction:
- `xpath` when XPath mapping path was used
- `percentage` when fallback path was used
It also logs exactness (`exact=yes/no`) for XPath matches. Note that `exact=yes` is only set for
a full path match with correct indices; index-insensitive and ancestor matches always log `exact=no`.
+497
View File
@@ -0,0 +1,497 @@
#include "ChapterXPathIndexer.h"
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <limits>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace {
// Anchor used for both mapping directions.
// textOffset is counted as visible (non-whitespace) bytes from chapter start.
// xpath points to the nearest element path at/near that offset.
struct XPathAnchor {
size_t textOffset = 0;
std::string xpath;
std::string xpathNoIndex; // precomputed removeIndices(xpath)
};
struct StackNode {
std::string tag;
int index = 1;
bool hasTextAnchor = false;
};
// ParserState is intentionally ephemeral and created per lookup call.
// It holds only one spine parse worth of data to avoid retaining structures
// that would increase long-lived heap usage on the ESP32-C3.
struct ParserState {
explicit ParserState(const int spineIndex) : spineIndex(spineIndex) { siblingCounters.emplace_back(); }
int spineIndex = 0;
int skipDepth = -1;
size_t totalTextBytes = 0;
std::vector<StackNode> stack;
std::vector<std::unordered_map<std::string, int>> siblingCounters;
std::vector<XPathAnchor> anchors;
std::string baseXPath() const { return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; }
// Canonicalize incoming KOReader XPath before matching:
// - remove all whitespace
// - lowercase tags
// - strip optional trailing /text()
// - strip trailing slash
static std::string normalizeXPath(const std::string& input) {
if (input.empty()) {
return "";
}
std::string out;
out.reserve(input.size());
for (char c : input) {
const unsigned char uc = static_cast<unsigned char>(c);
if (std::isspace(uc)) {
continue;
}
out.push_back(static_cast<char>(std::tolower(uc)));
}
const std::string textSuffix = "/text()";
const size_t textPos = out.rfind(textSuffix);
if (textPos != std::string::npos && textPos + textSuffix.size() == out.size()) {
out.erase(textPos);
}
while (!out.empty() && out.back() == '/') {
out.pop_back();
}
return out;
}
// Remove bracketed numeric predicates so paths can be compared even when
// index counters differ between parser implementations.
static std::string removeIndices(const std::string& xpath) {
std::string out;
out.reserve(xpath.size());
bool inBracket = false;
for (char c : xpath) {
if (c == '[') {
inBracket = true;
continue;
}
if (c == ']') {
inBracket = false;
continue;
}
if (!inBracket) {
out.push_back(c);
}
}
return out;
}
static int pathDepth(const std::string& xpath) {
int depth = 0;
for (char c : xpath) {
if (c == '/') {
depth++;
}
}
return depth;
}
// Resolve a path to the best anchor offset.
// If exact node path is not found, progressively trim trailing segments and
// match ancestors to obtain a stable approximate location.
bool pickBestAnchorByPath(const std::string& targetPath, const bool ignoreIndices, size_t& outTextOffset,
bool& outExact) const {
if (targetPath.empty() || anchors.empty()) {
return false;
}
const std::string normalizedTarget = ignoreIndices ? removeIndices(targetPath) : targetPath;
std::string probe = normalizedTarget;
bool exactProbe = true;
while (!probe.empty()) {
int bestDepth = -1;
size_t bestOffset = 0;
bool found = false;
for (const auto& anchor : anchors) {
const std::string& anchorPath = ignoreIndices ? anchor.xpathNoIndex : anchor.xpath;
if (anchorPath == probe) {
const int depth = pathDepth(anchorPath);
if (!found || depth > bestDepth || (depth == bestDepth && anchor.textOffset < bestOffset)) {
found = true;
bestDepth = depth;
bestOffset = anchor.textOffset;
}
}
}
if (found) {
outTextOffset = bestOffset;
outExact = exactProbe;
return true;
}
const size_t lastSlash = probe.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
break;
}
probe.erase(lastSlash);
exactProbe = false;
}
return false;
}
static std::string toLower(std::string value) {
for (char& c : value) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return value;
}
// Elements that should not contribute text position anchors.
static bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; }
static bool isWhitespaceOnly(const XML_Char* text, const int len) {
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
return false;
}
}
return true;
}
// Count non-whitespace bytes to keep offsets stable against formatting-only
// differences and indentation in source XHTML.
static size_t countVisibleBytes(const XML_Char* text, const int len) {
size_t count = 0;
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
count++;
}
}
return count;
}
int bodyDepth() const {
for (int i = static_cast<int>(stack.size()) - 1; i >= 0; i--) {
if (stack[i].tag == "body") {
return i;
}
}
return -1;
}
bool insideBody() const { return bodyDepth() >= 0; }
std::string currentXPath() const {
const int bodyIdx = bodyDepth();
if (bodyIdx < 0) {
return baseXPath();
}
std::string xpath = baseXPath();
for (size_t i = static_cast<size_t>(bodyIdx + 1); i < stack.size(); i++) {
xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]";
}
return xpath;
}
// Adds first anchor for an element when text begins and periodic anchors in
// longer runs so matching has sufficient granularity without exploding memory.
void addAnchorIfNeeded() {
if (!insideBody() || stack.empty()) {
return;
}
if (!stack.back().hasTextAnchor) {
const std::string xpath = currentXPath();
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
stack.back().hasTextAnchor = true;
} else if (anchors.empty() || totalTextBytes - anchors.back().textOffset >= 192) {
const std::string xpath = currentXPath();
if (anchors.empty() || anchors.back().xpath != xpath) {
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
}
}
}
void onStartElement(const XML_Char* rawName) {
std::string name = toLower(rawName ? rawName : "");
const size_t depth = stack.size();
if (siblingCounters.size() <= depth) {
siblingCounters.resize(depth + 1);
}
const int siblingIndex = ++siblingCounters[depth][name];
stack.push_back({name, siblingIndex, false});
siblingCounters.emplace_back();
if (skipDepth < 0 && isSkippableTag(name)) {
skipDepth = static_cast<int>(stack.size()) - 1;
}
}
void onEndElement() {
if (stack.empty()) {
return;
}
if (skipDepth == static_cast<int>(stack.size()) - 1) {
skipDepth = -1;
}
stack.pop_back();
if (!siblingCounters.empty()) {
siblingCounters.pop_back();
}
}
void onCharacterData(const XML_Char* text, const int len) {
if (skipDepth >= 0 || len <= 0 || !insideBody() || isWhitespaceOnly(text, len)) {
return;
}
addAnchorIfNeeded();
totalTextBytes += countVisibleBytes(text, len);
}
std::string chooseXPath(const float intraSpineProgress) const {
if (anchors.empty()) {
return baseXPath();
}
if (totalTextBytes == 0) {
return anchors.front().xpath;
}
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
const size_t target = static_cast<size_t>(clampedProgress * static_cast<float>(totalTextBytes));
// upper_bound returns the first anchor strictly after target; step back to get
// the last anchor at-or-before target (the element the user is currently inside).
auto it = std::upper_bound(anchors.begin(), anchors.end(), target,
[](const size_t value, const XPathAnchor& anchor) { return value < anchor.textOffset; });
if (it != anchors.begin()) {
--it;
}
return it->xpath;
}
// Convert path -> progress ratio by matching to nearest available anchor.
bool chooseProgressForXPath(const std::string& xpath, float& outIntraSpineProgress, bool& outExactMatch) const {
if (anchors.empty()) {
return false;
}
const std::string normalized = normalizeXPath(xpath);
if (normalized.empty()) {
return false;
}
size_t matchedOffset = 0;
bool exact = false;
const char* matchTier = nullptr;
bool matched = pickBestAnchorByPath(normalized, false, matchedOffset, exact);
if (matched) {
matchTier = exact ? "exact" : "ancestor";
} else {
bool exactRaw = false;
matched = pickBestAnchorByPath(normalized, true, matchedOffset, exactRaw);
if (matched) {
exact = false;
matchTier = exactRaw ? "index-insensitive" : "index-insensitive-ancestor";
}
}
if (!matched) {
LOG_DBG("KOX", "Reverse: spine=%d no anchor match for '%s' (%zu anchors)", spineIndex, normalized.c_str(),
anchors.size());
return false;
}
outExactMatch = exact;
if (totalTextBytes == 0) {
outIntraSpineProgress = 0.0f;
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu -> progress=0.0 (no text)", spineIndex, matchTier,
matchedOffset);
return true;
}
outIntraSpineProgress = static_cast<float>(matchedOffset) / static_cast<float>(totalTextBytes);
outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress));
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f", spineIndex, matchTier, matchedOffset,
totalTextBytes, outIntraSpineProgress);
return true;
}
};
void XMLCALL onStartElement(void* userData, const XML_Char* name, const XML_Char**) {
auto* state = static_cast<ParserState*>(userData);
state->onStartElement(name);
}
void XMLCALL onEndElement(void* userData, const XML_Char*) {
auto* state = static_cast<ParserState*>(userData);
state->onEndElement();
}
void XMLCALL onCharacterData(void* userData, const XML_Char* text, const int len) {
auto* state = static_cast<ParserState*>(userData);
state->onCharacterData(text, len);
}
void XMLCALL onDefaultHandlerExpand(void* userData, const XML_Char* text, const int len) {
// The default handler fires for comments, PIs, DOCTYPE, and entity references.
// Only forward entity references (&..;) to avoid skewing text offsets with
// non-visible markup.
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
return;
}
for (int i = 1; i < len - 1; ++i) {
if (text[i] == '<' || text[i] == '>') {
return;
}
}
auto* state = static_cast<ParserState*>(userData);
state->onCharacterData(text, len);
}
// Parse one spine item and return a fully populated ParserState.
// Returns std::nullopt if validation, I/O, or XML parse fails.
static std::optional<ParserState> parseSpineItem(const std::shared_ptr<Epub>& epub, const int spineIndex) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return std::nullopt;
}
const auto spineItem = epub->getSpineItem(spineIndex);
if (spineItem.href.empty()) {
return std::nullopt;
}
size_t chapterSize = 0;
uint8_t* chapterBytes = epub->readItemContentsToBytes(spineItem.href, &chapterSize, false);
if (!chapterBytes || chapterSize == 0) {
free(chapterBytes);
return std::nullopt;
}
ParserState state(spineIndex);
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
free(chapterBytes);
LOG_ERR("KOX", "Failed to allocate XML parser for spine=%d", spineIndex);
return std::nullopt;
}
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, onStartElement, onEndElement);
XML_SetCharacterDataHandler(parser, onCharacterData);
XML_SetDefaultHandlerExpand(parser, onDefaultHandlerExpand);
const bool parseOk = XML_Parse(parser, reinterpret_cast<const char*>(chapterBytes), static_cast<int>(chapterSize),
XML_TRUE) != XML_STATUS_ERROR;
if (!parseOk) {
LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
}
XML_ParserFree(parser);
free(chapterBytes);
if (!parseOk) {
return std::nullopt;
}
return state;
}
} // namespace
std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
const auto state = parseSpineItem(epub, spineIndex);
if (!state) {
return "";
}
const std::string result = state->chooseXPath(intraSpineProgress);
LOG_DBG("KOX", "Forward: spine=%d progress=%.3f anchors=%zu textBytes=%zu -> %s", spineIndex, intraSpineProgress,
state->anchors.size(), state->totalTextBytes, result.c_str());
return result;
}
bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& epub, const int spineIndex,
const std::string& xpath, float& outIntraSpineProgress,
bool& outExactMatch) {
outIntraSpineProgress = 0.0f;
outExactMatch = false;
if (xpath.empty()) {
return false;
}
const auto state = parseSpineItem(epub, spineIndex);
if (!state) {
return false;
}
LOG_DBG("KOX", "Reverse: spine=%d anchors=%zu textBytes=%zu for '%s'", spineIndex, state->anchors.size(),
state->totalTextBytes, xpath.c_str());
return state->chooseProgressForXPath(xpath, outIntraSpineProgress, outExactMatch);
}
bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) {
outSpineIndex = -1;
if (xpath.empty()) {
return false;
}
const std::string normalized = ParserState::normalizeXPath(xpath);
const std::string key = "/docfragment[";
const size_t pos = normalized.find(key);
if (pos == std::string::npos) {
LOG_DBG("KOX", "No DocFragment in xpath: '%s'", xpath.c_str());
return false;
}
const size_t start = pos + key.size();
size_t end = start;
while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end]))) {
end++;
}
if (end == start || end >= normalized.size() || normalized[end] != ']') {
return false;
}
const std::string value = normalized.substr(start, end - start);
const long parsed = std::strtol(value.c_str(), nullptr, 10);
// KOReader uses 1-based DocFragment indices; convert to 0-based spine index.
if (parsed < 1 || parsed > std::numeric_limits<int>::max()) {
return false;
}
outSpineIndex = static_cast<int>(parsed) - 1;
return true;
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
/**
* Lightweight XPath/progress bridge for KOReader sync.
*
* Why this exists:
* - CrossPoint stores reading position as chapter/page.
* - KOReader sync uses XPath + percentage.
*
* This utility reparses exactly one spine XHTML item with Expat and builds
* transient text anchors (<xpath, textOffset>) so we can translate in both
* directions without keeping a full DOM in memory.
*
* Design constraints (ESP32-C3):
* - No persistent full-book structures.
* - Parse-on-demand and free memory immediately.
* - Keep fallback behavior deterministic if parsing/matching fails.
*/
class ChapterXPathIndexer {
public:
/**
* Convert an intra-spine progress ratio to the nearest element-level XPath.
*
* @param epub Loaded EPUB instance
* @param spineIndex Current spine item index
* @param intraSpineProgress Position within the spine item [0.0, 1.0]
* @return Best matching XPath for KOReader, or empty string on failure
*/
static std::string findXPathForProgress(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
/**
* Resolve a KOReader XPath to an intra-spine progress ratio.
*
* Matching strategy:
* 1) exact anchor path match,
* 2) index-insensitive path match,
* 3) ancestor fallback.
*
* @param epub Loaded EPUB instance
* @param spineIndex Spine item index to parse
* @param xpath Incoming KOReader XPath
* @param outIntraSpineProgress Resolved position within spine [0.0, 1.0]
* @param outExactMatch True only for full exact path match
* @return true if any match was resolved; false means caller should fallback
*/
static bool findProgressForXPath(const std::shared_ptr<Epub>& epub, int spineIndex, const std::string& xpath,
float& outIntraSpineProgress, bool& outExactMatch);
/**
* Parse DocFragment index from KOReader-style path segment:
* /body/DocFragment[N]/body/...
*
* KOReader uses 1-based DocFragment indices; N is converted to the 0-based
* spine index stored in outSpineIndex (i.e. outSpineIndex = N - 1).
*
* @param xpath KOReader XPath
* @param outSpineIndex 0-based spine index derived from DocFragment[N]
* @return true when DocFragment[N] exists and N is a valid integer >= 1
* (converted to 0-based outSpineIndex); false otherwise
*/
static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex);
};
+76 -33
View File
@@ -2,8 +2,11 @@
#include <Logging.h>
#include <algorithm>
#include <cmath>
#include "ChapterXPathIndexer.h"
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
KOReaderPosition result;
@@ -16,8 +19,13 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
// Calculate overall book progress (0.0-1.0)
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath with estimated paragraph position based on page
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
// Generate the best available XPath for the current chapter position.
// Prefer element-level XPaths from a lightweight XHTML reparse; fall back
// to a synthetic chapter-level path if parsing fails.
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);
@@ -36,34 +44,69 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.pageNumber = 0;
result.totalPages = 0;
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
if (!epub || epub->getSpineItemsCount() <= 0) {
return result;
}
// Use percentage-based lookup for both spine and page positioning
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
// Find the spine item that contains this byte position
const int spineCount = epub->getSpineItemsCount();
bool spineFound = false;
for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
result.spineIndex = i;
spineFound = true;
break;
float resolvedIntraSpineProgress = -1.0f;
bool xpathExactMatch = false;
bool usedXPathMapping = false;
int xpathSpineIndex = -1;
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
xpathSpineIndex < spineCount) {
float intraFromXPath = 0.0f;
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath,
xpathExactMatch)) {
result.spineIndex = xpathSpineIndex;
resolvedIntraSpineProgress = intraFromXPath;
usedXPathMapping = true;
}
}
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
// default to the last spine item so we map to the end of the book instead of the beginning.
if (!spineFound && spineCount > 0) {
result.spineIndex = spineCount - 1;
if (!usedXPathMapping) {
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
return result;
}
if (!std::isfinite(koPos.percentage)) {
return result;
}
const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f);
const size_t targetBytes = static_cast<size_t>(bookSize * sanitizedPercentage);
bool spineFound = false;
for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
result.spineIndex = i;
spineFound = true;
break;
}
}
if (!spineFound && spineCount > 0) {
result.spineIndex = spineCount - 1;
}
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;
if (spineSize > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
resolvedIntraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
resolvedIntraSpineProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
}
}
}
// Estimate page number within the spine item using percentage
// 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);
@@ -91,24 +134,24 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.totalPages = estimatedTotalPages;
if (spineSize > 0 && estimatedTotalPages > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) {
const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * static_cast<float>(estimatedTotalPages));
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
} else if (spineSize > 0 && estimatedTotalPages > 0) {
result.pageNumber = 0;
}
}
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
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,
usedXPathMapping ? "xpath" : "percentage", xpathExactMatch ? "yes" : "no");
return result;
}
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
// Use 0-based DocFragment indices for KOReader
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
// Avoid specifying paragraph numbers as they may not exist in the target document
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
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";
}
+15 -8
View File
@@ -27,9 +27,16 @@ struct KOReaderPosition {
* CrossPoint tracks position as (spineIndex, pageNumber).
* KOReader uses XPath-like strings + percentage.
*
* Since CrossPoint discards HTML structure during parsing, we generate
* synthetic XPath strings based on spine index, using percentage as the
* primary sync mechanism.
* Forward mapping (CrossPoint -> KOReader):
* - Prefer element-level XPath extracted from current spine XHTML.
* - Fallback to synthetic chapter XPath if extraction fails.
*
* Reverse mapping (KOReader -> CrossPoint):
* - Prefer incoming XPath (DocFragment + element path) when resolvable.
* - Fallback to percentage-based approximation when XPath is missing/invalid.
*
* This keeps behavior stable on low-memory devices while improving round-trip
* sync precision when KOReader provides detailed paths.
*/
class ProgressMapper {
public:
@@ -45,8 +52,9 @@ class ProgressMapper {
/**
* Convert KOReader position to CrossPoint format.
*
* Note: The returned pageNumber may be approximate since different
* rendering settings produce different page counts.
* Uses XPath-first resolution when possible and percentage fallback otherwise.
* Returned pageNumber can still be approximate because page counts differ
* across renderer/font/layout settings.
*
* @param epub The EPUB book
* @param koPos KOReader position
@@ -60,8 +68,7 @@ class ProgressMapper {
private:
/**
* Generate XPath for KOReader compatibility.
* Format: /body/DocFragment[spineIndex+1]/body
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
* Fallback format: /body/DocFragment[spineIndex + 1]/body
*/
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
static std::string generateXPath(int spineIndex);
};