From fb4d0c6572826e0e28b1741a91ea0568a2ae808d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 1 Apr 2026 10:38:02 +0200 Subject: [PATCH] Refactor and extend docs --- .../koreader-sync-xpath-mapping.md | 2 + docs/contributing/koreader-synchronization.md | 115 +++ .../ChapterXPathForwardMapper.cpp | 163 ++++ lib/KOReaderSync/ChapterXPathForwardMapper.h | 12 + lib/KOReaderSync/ChapterXPathIndexer.cpp | 785 +----------------- .../ChapterXPathIndexerInternal.cpp | 315 +++++++ .../ChapterXPathIndexerInternal.h | 31 + lib/KOReaderSync/ChapterXPathIndexerState.h | 104 +++ .../ChapterXPathReverseMapper.cpp | 233 ++++++ lib/KOReaderSync/ChapterXPathReverseMapper.h | 13 + open-x4-sdk | 2 +- 11 files changed, 1000 insertions(+), 775 deletions(-) create mode 100644 docs/contributing/koreader-synchronization.md create mode 100644 lib/KOReaderSync/ChapterXPathForwardMapper.cpp create mode 100644 lib/KOReaderSync/ChapterXPathForwardMapper.h create mode 100644 lib/KOReaderSync/ChapterXPathIndexerInternal.cpp create mode 100644 lib/KOReaderSync/ChapterXPathIndexerInternal.h create mode 100644 lib/KOReaderSync/ChapterXPathIndexerState.h create mode 100644 lib/KOReaderSync/ChapterXPathReverseMapper.cpp create mode 100644 lib/KOReaderSync/ChapterXPathReverseMapper.h diff --git a/docs/contributing/koreader-sync-xpath-mapping.md b/docs/contributing/koreader-sync-xpath-mapping.md index ea38ce45..f437dd6b 100644 --- a/docs/contributing/koreader-sync-xpath-mapping.md +++ b/docs/contributing/koreader-sync-xpath-mapping.md @@ -2,6 +2,8 @@ This note documents how CrossPoint maps reading positions to and from KOReader sync payloads. +Related architecture overview: [koreader-synchronization.md](koreader-synchronization.md) + ## Problem CrossPoint internally stores position as: diff --git a/docs/contributing/koreader-synchronization.md b/docs/contributing/koreader-synchronization.md new file mode 100644 index 00000000..31aa74ad --- /dev/null +++ b/docs/contributing/koreader-synchronization.md @@ -0,0 +1,115 @@ +# KOReader Synchronization Architecture + +This document explains the intent and internal structure of the KOReader synchronization code in CrossPoint. + +Scope: +- Synchronization logic that maps between CrossPoint reading position and KOReader sync payloads. +- Module boundaries and responsibilities. +- Matching rules, fallback strategy, and expected behavior. + +For XPath-specific details and examples, see [koreader-sync-xpath-mapping.md](koreader-sync-xpath-mapping.md). + +## Goals + +The synchronization layer is designed to: +- Be robust on constrained devices (ESP32-C3 memory constraints). +- Be deterministic and debuggable when mapping positions. +- Keep transport/client logic separated from parsing/mapping logic. +- Prefer precise anchors when available, but degrade gracefully. + +## Data Model Mismatch + +CrossPoint stores position as chapter/page-centric state. +KOReader sync payload stores position as XPath-like anchor plus percentage. + +Because layout engines differ, page equality cannot be guaranteed across devices. +The synchronization strategy therefore combines: +- Structural anchor mapping (XPath). +- Percent-based fallback. +- Paragraph LUT refinement when available. + +## Module Responsibilities + +### Client / orchestration + +- [lib/KOReaderSync/KOReaderSyncClient.cpp](../../lib/KOReaderSync/KOReaderSyncClient.cpp) + - HTTP calls and payload exchange. + +- [lib/KOReaderSync/ProgressMapper.cpp](../../lib/KOReaderSync/ProgressMapper.cpp) + - High-level mapping from app state to KOReader payload and back. + - Chooses XPath path or percentage fallback. + +### XPath indexing facade + +- [lib/KOReaderSync/ChapterXPathIndexer.h](../../lib/KOReaderSync/ChapterXPathIndexer.h) +- [lib/KOReaderSync/ChapterXPathIndexer.cpp](../../lib/KOReaderSync/ChapterXPathIndexer.cpp) + - Public API consumed by ProgressMapper. + - Thin facade over forward/reverse mapper internals. + - Utility extraction helpers (DocFragment index, paragraph index). + +### Forward mapping engine + +- [lib/KOReaderSync/ChapterXPathForwardMapper.cpp](../../lib/KOReaderSync/ChapterXPathForwardMapper.cpp) + - Maps intra-spine progress to XPath. + - Emits /text()[N].M for body-level text-node locations. + +### Reverse mapping engine + +- [lib/KOReaderSync/ChapterXPathReverseMapper.cpp](../../lib/KOReaderSync/ChapterXPathReverseMapper.cpp) + - Maps XPath to intra-spine progress. + - Supports exact and tolerant matching tiers. + - Handles /text()[N].M codepoint offsets. + +### Shared parser/state/utilities + +- [lib/KOReaderSync/ChapterXPathIndexerInternal.cpp](../../lib/KOReaderSync/ChapterXPathIndexerInternal.cpp) +- [lib/KOReaderSync/ChapterXPathIndexerInternal.h](../../lib/KOReaderSync/ChapterXPathIndexerInternal.h) + - UTF-8 helpers, XPath normalization, parse runner, and chapter text-byte counting. + +- [lib/KOReaderSync/ChapterXPathIndexerState.h](../../lib/KOReaderSync/ChapterXPathIndexerState.h) + - Shared stack model and generic Expat callback adapters. + - Common parser code pattern used by both forward/reverse engines. + +## Core Logic + +### Forward (CrossPoint -> KOReader) + +1. Decompress one spine XHTML to a temporary file. +2. Count total visible text bytes. +3. Cache that total per spine (cache-path + spine index + href) so repeated + mappings for the same chapter can skip the expensive counting pass. +4. Convert intra-spine progress to target visible-byte offset. +5. Stream parse and stop at target. +6. Emit anchor: + - element XPath, or + - /text()[N].M when in body-level text-node context. + +### Reverse (KOReader -> CrossPoint) + +1. Decompress one spine XHTML to a temporary file. +2. Stream parse chapter while evaluating candidate matches. +3. Resolve best tier in this order: + - exact + - exact-no-index + - ancestor + - ancestor-no-index +4. Convert resolved byte offset to intra-spine progress. + +For text-node anchors /text()[N].M: +- N is treated as 1-based text node index. +- M is treated as 0-based codepoint offset. + +## Fallback Strategy + +When XPath mapping fails or is ambiguous: +- Fall back to percentage-driven chapter/page estimation. +- Use paragraph LUT refinement where available. + +This guarantees user progress continuity even for malformed or sparse content. + +## Constraints and Non-Goals + +- No full DOM materialization for entire books. +- Parse only one spine item on demand. +- Keep memory usage bounded and transient. +- Do not attempt pixel-perfect page parity with KOReader. diff --git a/lib/KOReaderSync/ChapterXPathForwardMapper.cpp b/lib/KOReaderSync/ChapterXPathForwardMapper.cpp new file mode 100644 index 00000000..1a42ef08 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathForwardMapper.cpp @@ -0,0 +1,163 @@ +#include "ChapterXPathForwardMapper.h" + +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathIndexerState.h" + +#include +#include +#include + +#include +#include +#include + +namespace ChapterXPathIndexerInternal { + +namespace { + +// Forward mapper: translate intra-spine progress to a KOReader-compatible XPath. +// Strategy: +// 1) Count total visible text bytes in chapter. +// 2) Stream parse again and stop when target byte offset is reached. +// 3) Emit either an element path or /text()[N].M when at body text-node level. + +struct ForwardState : StackState { + int spineIndex; + size_t targetOffset; + std::string result; + bool found = false; + XML_Parser parser = nullptr; + + int bodyTextNodeCount = 0; + size_t codepointsInBodyTextNode = 0; + bool inBodyTextNode = false; + + ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {} + + void onStartElement(const XML_Char* rawName) { + inBodyTextNode = false; + pushElement(rawName); + } + + void onEndElement() { + inBodyTextNode = false; + popElement(); + } + + void onCharData(const XML_Char* text, const int len) { + if (shouldSkipText(len) || found) { + return; + } + + const bool atBodyLevel = bodyIdx() + 1 == static_cast(stack.size()); + if (atBodyLevel && !inBodyTextNode) { + inBodyTextNode = true; + bodyTextNodeCount++; + codepointsInBodyTextNode = 0; + } + + if (isWhitespaceOnly(text, len)) { + if (atBodyLevel) { + codepointsInBodyTextNode += countUtf8Codepoints(text, len); + } + return; + } + + const size_t visible = countVisibleBytes(text, len); + if (totalTextBytes + visible >= targetOffset) { + if (atBodyLevel && bodyTextNodeCount > 0) { + // KOReader/crengine text-point semantics use codepoint offsets. + const size_t targetVisibleByteInChunk = targetOffset - totalTextBytes; + const size_t cpInChunk = codepointAtVisibleByte(text, len, targetVisibleByteInChunk); + const size_t charOff = codepointsInBodyTextNode + cpInChunk; + result = + currentXPath(spineIndex) + "/text()[" + std::to_string(bodyTextNodeCount) + "]." + std::to_string(charOff); + } else { + result = currentXPath(spineIndex); + } + found = true; + if (parser) { + XML_StopParser(parser, XML_FALSE); + } + return; + } + + totalTextBytes += visible; + if (atBodyLevel) { + codepointsInBodyTextNode += countUtf8Codepoints(text, len); + } + } +}; + +std::string makeSpineCacheKey(const std::shared_ptr& epub, const int spineIndex) { + if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { + return ""; + } + const auto spineItem = epub->getSpineItem(spineIndex); + return epub->getCachePath() + "|" + std::to_string(spineIndex) + "|" + spineItem.href; +} + +size_t getTotalTextBytesCached(const std::shared_ptr& epub, const int spineIndex, const std::string& tmpPath) { + static std::unordered_map sTotalBytesBySpine; + + const std::string key = makeSpineCacheKey(epub, spineIndex); + if (!key.empty()) { + const auto it = sTotalBytesBySpine.find(key); + if (it != sTotalBytesBySpine.end()) { + return it->second; + } + } + + const size_t totalTextBytes = countTotalTextBytes(tmpPath); + if (!key.empty()) { + sTotalBytesBySpine[key] = totalTextBytes; + } + return totalTextBytes; +} + +} // namespace + +std::string findXPathForProgressInternal(const std::shared_ptr& epub, const int spineIndex, + const float intraSpineProgress) { + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { + return ""; + } + + const size_t totalTextBytes = getTotalTextBytesCached(epub, spineIndex, 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)); + + 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, parserStartCb, parserEndCb); + XML_SetCharacterDataHandler(parser, parserCharCb); + XML_SetDefaultHandlerExpand(parser, parserDefaultCb); + 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; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathForwardMapper.h b/lib/KOReaderSync/ChapterXPathForwardMapper.h new file mode 100644 index 00000000..d37dc054 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathForwardMapper.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +std::string findXPathForProgressInternal(const std::shared_ptr& epub, int spineIndex, float intraSpineProgress); + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexer.cpp b/lib/KOReaderSync/ChapterXPathIndexer.cpp index 49cd4354..1e950bf8 100644 --- a/lib/KOReaderSync/ChapterXPathIndexer.cpp +++ b/lib/KOReaderSync/ChapterXPathIndexer.cpp @@ -1,793 +1,30 @@ #include "ChapterXPathIndexer.h" -#include +#include "ChapterXPathForwardMapper.h" +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathReverseMapper.h" + #include -#include -#include #include #include #include #include -#include -#include -namespace { +using namespace ChapterXPathIndexerInternal; -// ---- Utility ---- - -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; -} - -size_t countUtf8Codepoints(const XML_Char* text, const int len) { - size_t count = 0; - for (int i = 0; i < len; i++) { - if ((static_cast(text[i]) & 0xC0) != 0x80) { - count++; - } - } - return count; -} - -// Map a visible-byte index within a UTF-8 chunk to a 0-based codepoint offset. -// Returns the codepoint index of the character that contains the targetVisibleByte-th -// visible (non-whitespace) byte. -size_t codepointAtVisibleByte(const XML_Char* text, const int len, const size_t targetVisibleByte) { - size_t codepoints = 0; - size_t visibleBytes = 0; - for (int i = 0; i < len; i++) { - const unsigned char uc = static_cast(text[i]); - const bool isLeadByte = (uc & 0xC0) != 0x80; - if (isLeadByte) { - codepoints++; - } - if (!std::isspace(uc)) { - if (visibleBytes == targetVisibleByte) { - return codepoints - 1; - } - visibleBytes++; - } - } - return codepoints; -} - -// Count visible (non-whitespace) bytes before the target codepoint index. -// targetCodepointOffset is 0-based and measured in Unicode codepoints. -size_t visibleBytesBeforeCodepoint(const XML_Char* text, const int len, const size_t targetCodepointOffset) { - size_t visibleBytes = 0; - size_t codepointIndex = 0; - - int i = 0; - while (i < len) { - if (codepointIndex >= targetCodepointOffset) { - break; - } - - const int cpStart = i; - i++; - while (i < len && (static_cast(text[i]) & 0xC0) == 0x80) { - i++; - } - - for (int j = cpStart; j < i; j++) { - if (!std::isspace(static_cast(text[j]))) { - visibleBytes++; - } - } - - codepointIndex++; - } - - return visibleBytes; -} - -// Canonicalize a KOReader XPath for comparison: -// - remove whitespace, lowercase, strip /text() with optional char offset, -// strip trailing .N text-child-index suffix on the last segment (e.g. br.0 → br). -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 suffix: /text().327, /text()[94], /text()[94].152. - 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[afterText] == '[') { - out.erase(textPos); - } - } - - // Strip trailing .N text-child-index suffix on the last path segment - // (KOReader notation, e.g. /div/br.0 → /div/br). - const size_t lastSlash = out.rfind('/'); - if (lastSlash != std::string::npos) { - const size_t dotPos = out.find('.', lastSlash + 1); - if (dotPos != std::string::npos && dotPos + 1 < out.size()) { - bool allDigits = true; - for (size_t i = dotPos + 1; i < out.size(); i++) { - if (!std::isdigit(static_cast(out[i]))) { - allDigits = false; - break; - } - } - if (allDigits) { - out.erase(dotPos); - } - } - } - - 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 hasText = false; -}; - -struct StackState { - int skipDepth = -1; - size_t totalTextBytes = 0; - std::vector stack; - std::vector> siblingCounters; - - StackState() { siblingCounters.emplace_back(); } - - 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); - } - 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; - } - } - - void popElement() { - if (stack.empty()) { - return; - } - if (skipDepth == static_cast(stack.size()) - 1) { - skipDepth = -1; - } - stack.pop_back(); - if (!siblingCounters.empty()) { - siblingCounters.pop_back(); - } - } - - int bodyIdx() const { - for (int i = static_cast(stack.size()) - 1; i >= 0; i--) { - if (stack[i].tag == "body") { - return i; - } - } - return -1; - } - - bool insideBody() const { return bodyIdx() >= 0; } - - 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; - } - for (size_t i = static_cast(bi + 1); i < stack.size(); i++) { - xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]"; - } - return xpath; - } - - bool shouldSkipText(const int len) const { return skipDepth >= 0 || len <= 0 || !insideBody(); } -}; - -// ---- Decompress spine item to temp file ---- - -std::string decompressToTempFile(const std::shared_ptr& epub, const int spineIndex) { - if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { - return ""; - } - - const auto spineItem = epub->getSpineItem(spineIndex); - if (spineItem.href.empty()) { - return ""; - } - - 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 ""; - } - - 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 ""; - } - - 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; -} - -// ============================================================ -// 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; - - // Body-level text-node tracking for text()[N].M XPath emission. - // crengine counts ALL text nodes (including whitespace-only) and uses - // 0-based Unicode codepoint offsets for .M. - int bodyTextNodeCount = 0; - size_t codepointsInBodyTextNode = 0; - bool inBodyTextNode = false; - - ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {} - - void pushElement(const XML_Char* rawName) { - inBodyTextNode = false; - StackState::pushElement(rawName); - } - - void popElement() { - inBodyTextNode = false; - StackState::popElement(); - } - - void onChar(const XML_Char* text, const int len) { - if (shouldSkipText(len) || found) { - return; - } - - // Track body-level text nodes: count ALL text nodes (including whitespace-only) - // to match crengine's DOM text-node indexing. - const bool atBodyLevel = bodyIdx() + 1 == static_cast(stack.size()); - if (atBodyLevel && !inBodyTextNode) { - inBodyTextNode = true; - bodyTextNodeCount++; - codepointsInBodyTextNode = 0; - } - - if (isWhitespaceOnly(text, len)) { - if (atBodyLevel) { - codepointsInBodyTextNode += countUtf8Codepoints(text, len); - } - return; - } - - const size_t visible = countVisibleBytes(text, len); - if (totalTextBytes + visible >= targetOffset) { - if (atBodyLevel && bodyTextNodeCount > 0) { - const size_t targetVisibleByteInChunk = targetOffset - totalTextBytes; - const size_t cpInChunk = codepointAtVisibleByte(text, len, targetVisibleByteInChunk); - const size_t charOff = codepointsInBodyTextNode + cpInChunk; - result = - currentXPath(spineIndex) + "/text()[" + std::to_string(bodyTextNodeCount) + "]." + std::to_string(charOff); - } else { - result = currentXPath(spineIndex); - } - found = true; - if (parser) { - XML_StopParser(parser, XML_FALSE); - } - return; - } - totalTextBytes += visible; - if (atBodyLevel) { - codepointsInBodyTextNode += countUtf8Codepoints(text, len); - } - } -}; - -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; - - // Text-node targeting: set when the xpath ends with /text()[N] or /text()[N].M. - // targetTextNodeIndex > 0 activates this mode; the parser then counts direct - // text children of the targetNorm element and matches at the Nth one. - int targetTextNodeIndex = 0; - int targetCharOffset = 0; - bool inParentTextNode = false; - size_t codepointsInCurrentTextNode = 0; - int currentTextNodeCount = 0; - - 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) { - // Parse /text()[N] or /text()[N].M from the raw xpath before normalization. - std::string raw = xpath; - for (char& c : raw) c = static_cast(std::tolower(static_cast(c))); - const std::string tnPat = "/text()["; - const size_t tnPos = raw.rfind(tnPat); - if (tnPos != std::string::npos) { - const size_t numStart = tnPos + tnPat.size(); - size_t numEnd = numStart; - while (numEnd < raw.size() && std::isdigit(static_cast(raw[numEnd]))) { - numEnd++; - } - if (numEnd > numStart && numEnd < raw.size() && raw[numEnd] == ']') { - const long nodeIdx = std::strtol(raw.substr(numStart, numEnd - numStart).c_str(), nullptr, 10); - if (nodeIdx >= 1) { - targetTextNodeIndex = static_cast(nodeIdx); - size_t after = numEnd + 1; // skip ']' - if (after < raw.size() && raw[after] == '.') { - after++; - size_t charEnd = after; - while (charEnd < raw.size() && std::isdigit(static_cast(raw[charEnd]))) { - charEnd++; - } - if (charEnd > after) { - const long charOff = std::strtol(raw.substr(after, charEnd - after).c_str(), nullptr, 10); - if (charOff >= 0) { - targetCharOffset = static_cast(charOff); - } - } - } - } - } - } - targetNorm = normalizeXPath(xpath); - targetNoIndex = removeIndices(targetNorm); - } - - // Shadow base-class element handlers to reset inParentTextNode on stack changes. - void pushElement(const XML_Char* rawName) { - inParentTextNode = false; - StackState::pushElement(rawName); - } - - void popElement() { - inParentTextNode = false; - StackState::popElement(); - } - - void onChar(const XML_Char* text, const int len) { - if (shouldSkipText(len)) { - return; - } - - const size_t visible = countVisibleBytes(text, len); - const size_t codepoints = countUtf8Codepoints(text, len); - - // Text-node targeting: count direct text children of targetNorm element. - if (targetTextNodeIndex > 0 && !stack.empty()) { - const std::string xpath = normalizeXPath(currentXPath(spineIndex)); - if (xpath == targetNorm) { - // Mark element as having text so revEnd won't fire checkMatch() for it. - stack.back().hasText = true; - if (!inParentTextNode) { - inParentTextNode = true; - currentTextNodeCount++; - codepointsInCurrentTextNode = 0; - } - if (currentTextNodeCount == targetTextNodeIndex && bestTier < MatchTier::EXACT) { - const size_t charOff = static_cast(targetCharOffset); - if (charOff >= codepointsInCurrentTextNode && charOff <= codepointsInCurrentTextNode + codepoints) { - const size_t cpInChunk = charOff - codepointsInCurrentTextNode; - const size_t pos = totalTextBytes + visibleBytesBeforeCodepoint(text, len, cpInChunk); - bestTier = MatchTier::EXACT; - bestDepth = pathDepth(xpath); - bestOffset = pos; - bestExact = true; - bestTierName = "text-node-exact"; - } - } - codepointsInCurrentTextNode += codepoints; - totalTextBytes += visible; - return; - } - } - - if (isWhitespaceOnly(text, len)) { - return; - } - - // Standard: check match once per element (at first text). - if (!stack.empty() && !stack.back().hasText) { - stack.back().hasText = true; - checkMatch(); - } - - totalTextBytes += visible; - } - - 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*) { - auto* state = static_cast(ud); - // Textless elements (e.g.
) never trigger onChar, so check for a match - // before popping. The byte offset recorded is the text seen so far, which - // is the correct position ("just before this element"). - if (!state->stack.empty() && !state->stack.back().hasText) { - state->checkMatch(); - } - state->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 -// ============================================================ +// Public facade used by ProgressMapper. It intentionally stays thin and delegates +// heavy parsing/mapping work to the internal forward/reverse modules. std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr& epub, const int spineIndex, const float intraSpineProgress) { - const std::string tmpPath = decompressToTempFile(epub, spineIndex); - if (tmpPath.empty()) { - return ""; - } - - // 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; + return findXPathForProgressInternal(epub, spineIndex, intraSpineProgress); } bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr& epub, const int spineIndex, const std::string& xpath, float& outIntraSpineProgress, bool& outExactMatch) { - outIntraSpineProgress = 0.0f; - outExactMatch = false; - - if (xpath.empty()) { - return false; - } - - const std::string tmpPath = decompressToTempFile(epub, spineIndex); - if (tmpPath.empty()) { - return false; - } - - // 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; + return findProgressForXPathInternal(epub, spineIndex, xpath, outIntraSpineProgress, outExactMatch); } bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) { @@ -816,7 +53,7 @@ bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath 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. + // XPath uses 1-based predicates; internal spine indexing is 0-based. if (parsed < 1 || parsed > std::numeric_limits::max()) { return false; } @@ -833,7 +70,6 @@ bool ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(const std::string& x const std::string normalized = normalizeXPath(xpath); - // Find /p[ after the second /body/ (the inner body inside DocFragment) const std::string bodyKey = "/body"; size_t secondBody = normalized.find(bodyKey); if (secondBody != std::string::npos) { @@ -857,6 +93,7 @@ bool ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(const std::string& x } const long parsed = std::strtol(normalized.substr(start, end - start).c_str(), nullptr, 10); + // Paragraph index is preserved as 1-based to match XPath p[N] convention. if (parsed < 1 || parsed > UINT16_MAX) { return false; } diff --git a/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp b/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp new file mode 100644 index 00000000..a81fd71e --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp @@ -0,0 +1,315 @@ +#include "ChapterXPathIndexerInternal.h" + +#include +#include + +#include +#include +#include + +namespace ChapterXPathIndexerInternal { + +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; +} + +size_t countUtf8Codepoints(const XML_Char* text, const int len) { + size_t count = 0; + for (int i = 0; i < len; i++) { + if ((static_cast(text[i]) & 0xC0) != 0x80) { + count++; + } + } + return count; +} + +size_t codepointAtVisibleByte(const XML_Char* text, const int len, const size_t targetVisibleByte) { + size_t codepoints = 0; + size_t visibleBytes = 0; + for (int i = 0; i < len; i++) { + const unsigned char uc = static_cast(text[i]); + const bool isLeadByte = (uc & 0xC0) != 0x80; + if (isLeadByte) { + codepoints++; + } + if (!std::isspace(uc)) { + if (visibleBytes == targetVisibleByte) { + return codepoints - 1; + } + visibleBytes++; + } + } + return codepoints; +} + +size_t visibleBytesBeforeCodepoint(const XML_Char* text, const int len, const size_t targetCodepointOffset) { + size_t visibleBytes = 0; + size_t codepointIndex = 0; + + int i = 0; + while (i < len) { + if (codepointIndex >= targetCodepointOffset) { + break; + } + + const int cpStart = i; + i++; + while (i < len && (static_cast(text[i]) & 0xC0) == 0x80) { + i++; + } + + for (int j = cpStart; j < i; j++) { + if (!std::isspace(static_cast(text[j]))) { + visibleBytes++; + } + } + + codepointIndex++; + } + + return visibleBytes; +} + +std::string normalizeXPath(const std::string& input) { + if (input.empty()) { + return ""; + } + + std::string out; + out.reserve(input.size()); + for (const char c : input) { + const unsigned char uc = static_cast(c); + if (std::isspace(uc)) { + continue; + } + out.push_back(static_cast(std::tolower(uc))); + } + + 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[afterText] == '[') { + out.erase(textPos); + } + } + + const size_t lastSlash = out.rfind('/'); + if (lastSlash != std::string::npos) { + const size_t dotPos = out.find('.', lastSlash + 1); + if (dotPos != std::string::npos && dotPos + 1 < out.size()) { + bool allDigits = true; + for (size_t i = dotPos + 1; i < out.size(); i++) { + if (!std::isdigit(static_cast(out[i]))) { + allDigits = false; + break; + } + } + if (allDigits) { + out.erase(dotPos); + } + } + } + + 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 (const 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 (const char c : xpath) { + if (c == '/') { + depth++; + } + } + return depth; +} + +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()] == '/'; +} + +std::string decompressToTempFile(const std::shared_ptr& epub, const int spineIndex) { + if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { + return ""; + } + + const auto spineItem = epub->getSpineItem(spineIndex); + if (spineItem.href.empty()) { + return ""; + } + + 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 ""; + } + + 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 ""; + } + + return tmpPath; +} + +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; +} + +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; +} + +namespace { + +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); + } +} + +} // namespace + +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; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexerInternal.h b/lib/KOReaderSync/ChapterXPathIndexerInternal.h new file mode 100644 index 00000000..4a9485b2 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerInternal.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +std::string toLowerStr(std::string value); + +bool isSkippableTag(const std::string& tag); +bool isWhitespaceOnly(const XML_Char* text, int len); + +size_t countVisibleBytes(const XML_Char* text, int len); +size_t countUtf8Codepoints(const XML_Char* text, int len); +size_t codepointAtVisibleByte(const XML_Char* text, int len, size_t targetVisibleByte); +size_t visibleBytesBeforeCodepoint(const XML_Char* text, int len, size_t targetCodepointOffset); + +std::string normalizeXPath(const std::string& input); +std::string removeIndices(const std::string& xpath); +int pathDepth(const std::string& xpath); +bool isAncestorPath(const std::string& prefix, const std::string& path); + +std::string decompressToTempFile(const std::shared_ptr& epub, int spineIndex); +bool runParse(XML_Parser parser, const std::string& path); +bool isEntityRef(const XML_Char* text, int len); +size_t countTotalTextBytes(const std::string& tmpPath); + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexerState.h b/lib/KOReaderSync/ChapterXPathIndexerState.h new file mode 100644 index 00000000..2c724b82 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerState.h @@ -0,0 +1,104 @@ +#pragma once + +#include "ChapterXPathIndexerInternal.h" + +#include +#include +#include + +namespace ChapterXPathIndexerInternal { + +// Shared parser state used by both forward and reverse mappers. +// It centralizes DOM-stack bookkeeping and XPath reconstruction so each mapper +// only implements its own match/emit logic. + +struct StackNode { + std::string tag; + int index = 1; + bool hasText = false; +}; + +struct StackState { + int skipDepth = -1; + size_t totalTextBytes = 0; + std::vector stack; + std::vector> siblingCounters; + + StackState() { siblingCounters.emplace_back(); } + + 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); + } + 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; + } + } + + void popElement() { + if (stack.empty()) { + return; + } + if (skipDepth == static_cast(stack.size()) - 1) { + skipDepth = -1; + } + stack.pop_back(); + if (!siblingCounters.empty()) { + siblingCounters.pop_back(); + } + } + + int bodyIdx() const { + for (int i = static_cast(stack.size()) - 1; i >= 0; i--) { + if (stack[i].tag == "body") { + return i; + } + } + return -1; + } + + bool insideBody() const { return bodyIdx() >= 0; } + + 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; + } + for (size_t i = static_cast(bi + 1); i < stack.size(); i++) { + xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]"; + } + return xpath; + } + + bool shouldSkipText(const int len) const { return skipDepth >= 0 || len <= 0 || !insideBody(); } +}; + +template +void XMLCALL parserStartCb(void* ud, const XML_Char* name, const XML_Char**) { + static_cast(ud)->onStartElement(name); +} + +template +void XMLCALL parserEndCb(void* ud, const XML_Char*) { + static_cast(ud)->onEndElement(); +} + +template +void XMLCALL parserCharCb(void* ud, const XML_Char* text, const int len) { + static_cast(ud)->onCharData(text, len); +} + +template +void XMLCALL parserDefaultCb(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + static_cast(ud)->onCharData(text, len); + } +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathReverseMapper.cpp b/lib/KOReaderSync/ChapterXPathReverseMapper.cpp new file mode 100644 index 00000000..1310081f --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathReverseMapper.cpp @@ -0,0 +1,233 @@ +#include "ChapterXPathReverseMapper.h" + +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathIndexerState.h" + +#include +#include +#include + +#include +#include +#include + +namespace ChapterXPathIndexerInternal { + +namespace { + +// Reverse mapper: translate KOReader XPath to intra-spine progress. +// Matching preference order is strict and deterministic: +// exact > exact-no-index > ancestor > ancestor-no-index. +// For /text()[N].M, M is treated as codepoint offset and converted back to +// internal visible-byte progress. + +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; + + int targetTextNodeIndex = 0; + int targetCharOffset = 0; + bool inParentTextNode = false; + size_t codepointsInCurrentTextNode = 0; + int currentTextNodeCount = 0; + + 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) { + // Parse optional /text()[N].M suffix before normalizing for element matching. + std::string raw = xpath; + for (char& c : raw) c = static_cast(std::tolower(static_cast(c))); + const std::string tnPat = "/text()["; + const size_t tnPos = raw.rfind(tnPat); + if (tnPos != std::string::npos) { + const size_t numStart = tnPos + tnPat.size(); + size_t numEnd = numStart; + while (numEnd < raw.size() && std::isdigit(static_cast(raw[numEnd]))) { + numEnd++; + } + if (numEnd > numStart && numEnd < raw.size() && raw[numEnd] == ']') { + const long nodeIdx = std::strtol(raw.substr(numStart, numEnd - numStart).c_str(), nullptr, 10); + if (nodeIdx >= 1) { + targetTextNodeIndex = static_cast(nodeIdx); + size_t after = numEnd + 1; + if (after < raw.size() && raw[after] == '.') { + after++; + size_t charEnd = after; + while (charEnd < raw.size() && std::isdigit(static_cast(raw[charEnd]))) { + charEnd++; + } + if (charEnd > after) { + const long charOff = std::strtol(raw.substr(after, charEnd - after).c_str(), nullptr, 10); + if (charOff >= 0) { + targetCharOffset = static_cast(charOff); + } + } + } + } + } + } + targetNorm = normalizeXPath(xpath); + targetNoIndex = removeIndices(targetNorm); + } + + void onStartElement(const XML_Char* rawName) { + inParentTextNode = false; + pushElement(rawName); + } + + void onEndElement() { + // Empty/textless elements can still be a valid anchor location. + if (!stack.empty() && !stack.back().hasText) { + checkMatch(); + } + inParentTextNode = false; + popElement(); + } + + void onCharData(const XML_Char* text, const int len) { + if (shouldSkipText(len)) { + return; + } + + const size_t visible = countVisibleBytes(text, len); + const size_t codepoints = countUtf8Codepoints(text, len); + + if (targetTextNodeIndex > 0 && !stack.empty()) { + const std::string xpath = normalizeXPath(currentXPath(spineIndex)); + if (xpath == targetNorm) { + stack.back().hasText = true; + if (!inParentTextNode) { + inParentTextNode = true; + currentTextNodeCount++; + codepointsInCurrentTextNode = 0; + } + if (currentTextNodeCount == targetTextNodeIndex && bestTier < MatchTier::EXACT) { + const size_t charOff = static_cast(targetCharOffset); + if (charOff >= codepointsInCurrentTextNode && charOff <= codepointsInCurrentTextNode + codepoints) { + const size_t cpInChunk = charOff - codepointsInCurrentTextNode; + const size_t pos = totalTextBytes + visibleBytesBeforeCodepoint(text, len, cpInChunk); + bestTier = MatchTier::EXACT; + bestDepth = pathDepth(xpath); + bestOffset = pos; + bestExact = true; + bestTierName = "text-node-exact"; + } + } + codepointsInCurrentTextNode += codepoints; + totalTextBytes += visible; + return; + } + } + + if (isWhitespaceOnly(text, len)) { + return; + } + + if (!stack.empty() && !stack.back().hasText) { + stack.back().hasText = true; + checkMatch(); + } + + totalTextBytes += visible; + } + + void checkMatch() { + 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; + } + } +}; + +} // namespace + +bool findProgressForXPathInternal(const std::shared_ptr& epub, const int spineIndex, const std::string& xpath, + float& outIntraSpineProgress, bool& outExactMatch) { + outIntraSpineProgress = 0.0f; + outExactMatch = false; + + if (xpath.empty()) { + return false; + } + + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { + return false; + } + + 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, parserStartCb, parserEndCb); + XML_SetCharacterDataHandler(parser, parserCharCb); + XML_SetDefaultHandlerExpand(parser, parserDefaultCb); + 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; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathReverseMapper.h b/lib/KOReaderSync/ChapterXPathReverseMapper.h new file mode 100644 index 00000000..9b2faf9b --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathReverseMapper.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +bool findProgressForXPathInternal(const std::shared_ptr& epub, int spineIndex, const std::string& xpath, + float& outIntraSpineProgress, bool& outExactMatch); + +} // namespace ChapterXPathIndexerInternal diff --git a/open-x4-sdk b/open-x4-sdk index 9f76376a..f14be998 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 9f76376a5cc7894cff9ca87bbdd34dab715d8a59 +Subproject commit f14be998ab510616ac2779153251630b7b203f51