diff --git a/lib/KOReaderSync/ChapterXPathIndexer.cpp b/lib/KOReaderSync/ChapterXPathIndexer.cpp index 4ddcdd3c..83c1768c 100644 --- a/lib/KOReaderSync/ChapterXPathIndexer.cpp +++ b/lib/KOReaderSync/ChapterXPathIndexer.cpp @@ -8,190 +8,155 @@ #include #include #include -#include #include #include #include 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. +// ---- Utility ---- -struct XPathAnchor { - size_t textOffset = 0; - std::string xpath; - std::string xpathNoIndex; // precomputed removeIndices(xpath) -}; +std::string toLowerStr(std::string value) { + for (char& c : value) { + c = static_cast(std::tolower(static_cast(c))); + } + return value; +} + +bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; } + +bool isWhitespaceOnly(const XML_Char* text, const int len) { + for (int i = 0; i < len; i++) { + if (!std::isspace(static_cast(text[i]))) { + return false; + } + } + return true; +} + +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(text[i]))) { + count++; + } + } + return count; +} + +// Canonicalize a KOReader XPath for comparison: +// - remove whitespace, lowercase, strip /text() with optional char offset. +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(c); + if (std::isspace(uc)) { + continue; + } + out.push_back(static_cast(std::tolower(uc))); + } + + // Strip /text() and any optional character offset suffix (e.g. /text().327). + const std::string textTag = "/text()"; + const size_t textPos = out.rfind(textTag); + if (textPos != std::string::npos) { + const size_t afterText = textPos + textTag.size(); + if (afterText == out.size() || out[afterText] == '.') { + out.erase(textPos); + } + } + + while (!out.empty() && out.back() == '/') { + out.pop_back(); + } + + return out; +} + +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; +} + +int pathDepth(const std::string& xpath) { + int depth = 0; + for (char c : xpath) { + if (c == '/') { + depth++; + } + } + return depth; +} + +// True if `prefix` is a proper ancestor path of `path` (prefix + "/" + ...). +bool isAncestorPath(const std::string& prefix, const std::string& path) { + return path.size() > prefix.size() && path.compare(0, prefix.size(), prefix) == 0 && path[prefix.size()] == '/'; +} + +// ---- Stack tracking shared between forward and reverse ---- struct StackNode { std::string tag; int index = 1; - bool hasTextAnchor = false; + bool hasText = 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; +struct StackState { int skipDepth = -1; size_t totalTextBytes = 0; - std::vector stack; std::vector> siblingCounters; - std::vector anchors; - std::string baseXPath() const { return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; } + StackState() { siblingCounters.emplace_back(); } - // 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 ""; + void pushElement(const XML_Char* rawName) { + std::string name = toLowerStr(rawName ? rawName : ""); + const size_t depth = stack.size(); + if (siblingCounters.size() <= depth) { + siblingCounters.resize(depth + 1); } - - std::string out; - out.reserve(input.size()); - for (char c : input) { - const unsigned char uc = static_cast(c); - if (std::isspace(uc)) { - continue; - } - out.push_back(static_cast(std::tolower(uc))); + const int sibIdx = ++siblingCounters[depth][name]; + stack.push_back({name, sibIdx, false}); + siblingCounters.emplace_back(); + if (skipDepth < 0 && isSkippableTag(name)) { + skipDepth = static_cast(stack.size()) - 1; } - - 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); - } + void popElement() { + if (stack.empty()) { + return; + } + if (skipDepth == static_cast(stack.size()) - 1) { + skipDepth = -1; + } + stack.pop_back(); + if (!siblingCounters.empty()) { + siblingCounters.pop_back(); } - 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(std::tolower(static_cast(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(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(text[i]))) { - count++; - } - } - return count; - } - - int bodyDepth() const { + int bodyIdx() const { for (int i = static_cast(stack.size()) - 1; i >= 0; i--) { if (stack[i].tag == "body") { return i; @@ -200,282 +165,351 @@ struct ParserState { return -1; } - bool insideBody() const { return bodyDepth() >= 0; } + bool insideBody() const { return bodyIdx() >= 0; } - std::string currentXPath() const { - const int bodyIdx = bodyDepth(); - if (bodyIdx < 0) { - return baseXPath(); + std::string currentXPath(const int spineIndex) const { + const int bi = bodyIdx(); + std::string xpath = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + if (bi < 0) { + return xpath; } - - std::string xpath = baseXPath(); - for (size_t i = static_cast(bodyIdx + 1); i < stack.size(); i++) { + for (size_t i = static_cast(bi + 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(stack.size()) - 1; - } - } - - void onEndElement() { - if (stack.empty()) { - return; - } - - if (skipDepth == static_cast(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(clampedProgress * static_cast(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(matchedOffset) / static_cast(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; - } + bool shouldSkipText(const int len) const { return skipDepth >= 0 || len <= 0 || !insideBody(); } }; -void XMLCALL onStartElement(void* userData, const XML_Char* name, const XML_Char**) { - auto* state = static_cast(userData); - state->onStartElement(name); -} +// ---- Decompress spine item to temp file ---- -void XMLCALL onEndElement(void* userData, const XML_Char*) { - auto* state = static_cast(userData); - state->onEndElement(); -} - -void XMLCALL onCharacterData(void* userData, const XML_Char* text, const int len) { - auto* state = static_cast(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(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 parseSpineItem(const std::shared_ptr& epub, const int spineIndex) { +std::string decompressToTempFile(const std::shared_ptr& epub, const int spineIndex) { if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { - return std::nullopt; + return ""; } const auto spineItem = epub->getSpineItem(spineIndex); if (spineItem.href.empty()) { - return std::nullopt; + return ""; } - // Phase 1: decompress EPUB entry to a temp file on SD. - // This keeps the ~1.3 KB uzlib_uncomp struct off the stack before Expat runs. const std::string tmpPath = epub->getCachePath() + "/.tmp_kox.html"; - { - if (Storage.exists(tmpPath.c_str())) { - Storage.remove(tmpPath.c_str()); - } - FsFile tmpFile; - if (!Storage.openFileForWrite("KOX", tmpPath, tmpFile)) { - LOG_ERR("KOX", "Failed to create temp file for spine=%d", spineIndex); - return std::nullopt; - } - constexpr size_t kChunkSize = 1024; - const bool ok = epub->readItemContentsToStream(spineItem.href, tmpFile, kChunkSize); - tmpFile.close(); - if (!ok) { - Storage.remove(tmpPath.c_str()); - LOG_ERR("KOX", "Failed to decompress spine=%d to temp file", spineIndex); - return std::nullopt; - } - } - - // Phase 2: parse the temp file with Expat in chunks. - ParserState state(spineIndex); - - XML_Parser parser = XML_ParserCreate(nullptr); - if (!parser) { + if (Storage.exists(tmpPath.c_str())) { Storage.remove(tmpPath.c_str()); - 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); - FsFile tmpFile; - bool parseOk = Storage.openFileForRead("KOX", tmpPath, tmpFile); - - if (parseOk) { - constexpr size_t kParseBufSize = 1024; - int done; - do { - void* const buf = XML_GetBuffer(parser, kParseBufSize); - if (!buf) { - parseOk = false; - break; - } - const size_t len = tmpFile.read(buf, kParseBufSize); - done = tmpFile.available() == 0; - if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { - parseOk = false; - break; - } - } while (!done); - tmpFile.close(); + if (!Storage.openFileForWrite("KOX", tmpPath, tmpFile)) { + LOG_ERR("KOX", "Failed to create temp file for spine=%d", spineIndex); + return ""; } - Storage.remove(tmpPath.c_str()); + constexpr size_t kChunkSize = 1024; + const bool ok = epub->readItemContentsToStream(spineItem.href, tmpFile, kChunkSize); + tmpFile.close(); - if (!parseOk) { - LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser), - XML_ErrorString(XML_GetErrorCode(parser))); + if (!ok) { + Storage.remove(tmpPath.c_str()); + LOG_ERR("KOX", "Failed to decompress spine=%d to temp file", spineIndex); + return ""; } + return tmpPath; +} + +// ---- Expat parse loop ---- +// Returns true on success or intentional stop (XML_ERROR_ABORTED). + +bool runParse(XML_Parser parser, const std::string& path) { + FsFile file; + if (!Storage.openFileForRead("KOX", path, file)) { + return false; + } + + constexpr size_t kBufSize = 1024; + bool ok = true; + int done; + do { + void* const buf = XML_GetBuffer(parser, kBufSize); + if (!buf) { + ok = false; + break; + } + const size_t len = file.read(buf, kBufSize); + done = file.available() == 0; + if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { + ok = (XML_GetErrorCode(parser) == XML_ERROR_ABORTED); + break; + } + } while (!done); + + file.close(); + return ok; +} + +// ---- Entity reference filter (shared by all parse modes) ---- + +bool isEntityRef(const XML_Char* text, const int len) { + if (len < 3 || text[0] != '&' || text[len - 1] != ';') { + return false; + } + for (int i = 1; i < len - 1; ++i) { + if (text[i] == '<' || text[i] == '>') { + return false; + } + } + return true; +} + +// ============================================================ +// Pass 1 — Lightweight byte counter (no XPath string building) +// ============================================================ + +struct ByteCounter { + int skipDepth = -1; + int bodyStartDepth = -1; + int depth = 0; + size_t totalTextBytes = 0; +}; + +void XMLCALL bcStart(void* ud, const XML_Char* name, const XML_Char**) { + auto* s = static_cast(ud); + const std::string tag = toLowerStr(name ? name : ""); + if (tag == "body" && s->bodyStartDepth < 0) { + s->bodyStartDepth = s->depth; + } + if (s->skipDepth < 0 && isSkippableTag(tag)) { + s->skipDepth = s->depth; + } + s->depth++; +} + +void XMLCALL bcEnd(void* ud, const XML_Char*) { + auto* s = static_cast(ud); + s->depth--; + if (s->depth == s->skipDepth) { + s->skipDepth = -1; + } + if (s->depth == s->bodyStartDepth) { + s->bodyStartDepth = -1; + } +} + +void XMLCALL bcChar(void* ud, const XML_Char* text, const int len) { + auto* s = static_cast(ud); + if (s->skipDepth >= 0 || s->bodyStartDepth < 0 || len <= 0 || isWhitespaceOnly(text, len)) { + return; + } + s->totalTextBytes += countVisibleBytes(text, len); +} + +void XMLCALL bcDefault(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + bcChar(ud, text, len); + } +} + +size_t countTotalTextBytes(const std::string& tmpPath) { + ByteCounter state; + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + return 0; + } + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, bcStart, bcEnd); + XML_SetCharacterDataHandler(parser, bcChar); + XML_SetDefaultHandlerExpand(parser, bcDefault); + runParse(parser, tmpPath); XML_ParserFree(parser); + return state.totalTextBytes; +} - if (!parseOk) { - return std::nullopt; +// ============================================================ +// Forward query: progress ratio → XPath (stop-early parse) +// ============================================================ + +struct ForwardState : StackState { + int spineIndex; + size_t targetOffset; + std::string result; + bool found = false; + XML_Parser parser = nullptr; + + ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {} + + void onChar(const XML_Char* text, const int len) { + if (shouldSkipText(len) || isWhitespaceOnly(text, len) || found) { + return; + } + + const size_t visible = countVisibleBytes(text, len); + if (totalTextBytes + visible >= targetOffset) { + result = currentXPath(spineIndex); + found = true; + if (parser) { + XML_StopParser(parser, XML_FALSE); + } + return; + } + totalTextBytes += visible; + } +}; + +void XMLCALL fwdStart(void* ud, const XML_Char* name, const XML_Char**) { + static_cast(ud)->pushElement(name); +} + +void XMLCALL fwdEnd(void* ud, const XML_Char*) { static_cast(ud)->popElement(); } + +void XMLCALL fwdChar(void* ud, const XML_Char* text, const int len) { + static_cast(ud)->onChar(text, len); +} + +void XMLCALL fwdDefault(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + fwdChar(ud, text, len); + } +} + +// ============================================================ +// Reverse query: XPath → progress ratio (full parse) +// ============================================================ + +enum class MatchTier : int { + NONE = 0, + ANCESTOR_NO_IDX = 1, + ANCESTOR = 2, + EXACT_NO_IDX = 3, + EXACT = 4, +}; + +struct ReverseState : StackState { + int spineIndex; + std::string targetNorm; + std::string targetNoIndex; + + MatchTier bestTier = MatchTier::NONE; + int bestDepth = -1; + size_t bestOffset = 0; + bool bestExact = false; + const char* bestTierName = nullptr; + + ReverseState(const int spineIndex, const std::string& xpath) + : spineIndex(spineIndex), targetNorm(normalizeXPath(xpath)), targetNoIndex(removeIndices(targetNorm)) {} + + void onChar(const XML_Char* text, const int len) { + if (shouldSkipText(len) || isWhitespaceOnly(text, len)) { + return; + } + + // Check match once per element (at first text). + if (!stack.empty() && !stack.back().hasText) { + stack.back().hasText = true; + checkMatch(); + } + + totalTextBytes += countVisibleBytes(text, len); } - return state; + void checkMatch() { + // Normalize our generated XPath the same way as the target so that + // "DocFragment" matches "docfragment". + const std::string xpath = normalizeXPath(currentXPath(spineIndex)); + const int depth = pathDepth(xpath); + + if (xpath == targetNorm) { + tryUpdate(MatchTier::EXACT, depth, "exact", true); + return; + } + if (isAncestorPath(xpath, targetNorm)) { + tryUpdate(MatchTier::ANCESTOR, depth, "ancestor", false); + return; + } + + const std::string xpathNoIdx = removeIndices(xpath); + if (xpathNoIdx == targetNoIndex) { + tryUpdate(MatchTier::EXACT_NO_IDX, depth, "index-insensitive", false); + } else if (isAncestorPath(xpathNoIdx, targetNoIndex)) { + tryUpdate(MatchTier::ANCESTOR_NO_IDX, depth, "index-insensitive-ancestor", false); + } + } + + void tryUpdate(const MatchTier tier, const int depth, const char* tierName, const bool isExact) { + if (tier > bestTier || (tier == bestTier && depth > bestDepth)) { + bestTier = tier; + bestDepth = depth; + bestOffset = totalTextBytes; + bestExact = isExact; + bestTierName = tierName; + } + } +}; + +void XMLCALL revStart(void* ud, const XML_Char* name, const XML_Char**) { + static_cast(ud)->pushElement(name); +} + +void XMLCALL revEnd(void* ud, const XML_Char*) { static_cast(ud)->popElement(); } + +void XMLCALL revChar(void* ud, const XML_Char* text, const int len) { + static_cast(ud)->onChar(text, len); +} + +void XMLCALL revDefault(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + revChar(ud, text, len); + } } } // namespace +// ============================================================ +// Public API +// ============================================================ + std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr& epub, const int spineIndex, const float intraSpineProgress) { - auto state = parseSpineItem(epub, spineIndex); - if (!state) { + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { 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; + // Pass 1: count total visible text bytes (lightweight, no XPath building). + const size_t totalTextBytes = countTotalTextBytes(tmpPath); + if (totalTextBytes == 0) { + Storage.remove(tmpPath.c_str()); + const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + LOG_DBG("KOX", "Forward: spine=%d no text, returning base xpath", spineIndex); + return base; + } + + const float clamped = std::max(0.0f, std::min(1.0f, intraSpineProgress)); + const size_t targetOffset = static_cast(clamped * static_cast(totalTextBytes)); + + // Pass 2: parse with full XPath tracking, stop as soon as target is reached. + ForwardState state(spineIndex, targetOffset); + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + Storage.remove(tmpPath.c_str()); + return ""; + } + state.parser = parser; + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, fwdStart, fwdEnd); + XML_SetCharacterDataHandler(parser, fwdChar); + XML_SetDefaultHandlerExpand(parser, fwdDefault); + runParse(parser, tmpPath); + XML_ParserFree(parser); + Storage.remove(tmpPath.c_str()); + + if (state.result.empty()) { + state.result = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + } + + LOG_DBG("KOX", "Forward: spine=%d progress=%.3f target=%zu/%zu -> %s", spineIndex, intraSpineProgress, targetOffset, + totalTextBytes, state.result.c_str()); + return state.result; } bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr& epub, const int spineIndex, @@ -488,14 +522,47 @@ bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr& epub return false; } - auto state = parseSpineItem(epub, spineIndex); - if (!state) { + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { 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); + // Single pass: match target XPath inline, count totalTextBytes to end. + ReverseState state(spineIndex, xpath); + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + Storage.remove(tmpPath.c_str()); + return false; + } + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, revStart, revEnd); + XML_SetCharacterDataHandler(parser, revChar); + XML_SetDefaultHandlerExpand(parser, revDefault); + const bool parseOk = runParse(parser, tmpPath); + + 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); + Storage.remove(tmpPath.c_str()); + + if (!parseOk || state.bestTier == MatchTier::NONE) { + LOG_DBG("KOX", "Reverse: spine=%d no match for '%s'", spineIndex, xpath.c_str()); + return false; + } + + outExactMatch = state.bestExact; + if (state.totalTextBytes == 0) { + outIntraSpineProgress = 0.0f; + } else { + outIntraSpineProgress = static_cast(state.bestOffset) / static_cast(state.totalTextBytes); + outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress)); + } + + LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f for '%s'", spineIndex, state.bestTierName, + state.bestOffset, state.totalTextBytes, outIntraSpineProgress, xpath.c_str()); + return true; } bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) { @@ -504,7 +571,7 @@ bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath return false; } - const std::string normalized = ParserState::normalizeXPath(xpath); + const std::string normalized = normalizeXPath(xpath); const std::string key = "/docfragment["; const size_t pos = normalized.find(key); if (pos == std::string::npos) { diff --git a/lib/KOReaderSync/ChapterXPathIndexer.h b/lib/KOReaderSync/ChapterXPathIndexer.h index 246fdecd..1af61b2d 100644 --- a/lib/KOReaderSync/ChapterXPathIndexer.h +++ b/lib/KOReaderSync/ChapterXPathIndexer.h @@ -12,9 +12,9 @@ * - 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 () so we can translate in both - * directions without keeping a full DOM in memory. + * This utility reparses exactly one spine XHTML item with Expat to translate + * between the two formats. It streams through the parse using O(1) memory + * (no anchor list), so it handles arbitrarily large chapters without OOM. * * Design constraints (ESP32-C3): * - No persistent full-book structures.