Files
Crosspoint/lib/KOReaderSync/ChapterXPathResolver.cpp
T
Wylan SwetsandClaude Sonnet 4.6 bf894fd343 fix: improve KOSync bidirectional position matching accuracy (#1897)
## Summary

**Goal:** Fix bidirectional KOSync position matching between CrossPoint
and KOReader so that syncing in either direction lands on the correct
page with character-level accuracy.

**Changes included:**

**Download — `toCrossPoint` (server XPath → CrossPoint page)**
- **XPath ancestry mode for structured elements**: The previous
`ParagraphStreamer` only tracked `<p>` elements. Replaced with a full
ancestor-walking mode that correctly resolves XPaths pointing into
`<li>`, `<ul>`, and other structured elements. Char offset within the
target element is bounded to the matched element's content only.
- **Slash-in-attribute-value corrupts depth tracking**:
`processByteInTag()` treated every `/` byte as a self-closing tag
marker, including `/` inside quoted attribute values (e.g.
`xmlns="http://..."`, `src="Links/image.jpg"`). This drove `htmlDepth`
to 0 prematurely, causing the ancestry search to exit far short of the
target paragraph. Fixed with `inAttrQuote` tracking.
- **Off-by-one in page formula**: `intra * totalPages` rounds up
incorrectly for last-page positions. Changed to `intra * (totalPages -
1)` to map the `[0, 1]` intra fraction correctly onto the `[0,
totalPages-1]` page range. Example: page 14 of 17 was returned as 15.

**Upload — `toKOReader` (CrossPoint page → server XPath)**
- **Off-by-one in page-to-intra formula**: Symmetric fix — `pageNumber /
totalPages` changed to `pageNumber / (totalPages - 1)`, with the guard
updated from `> 0` to `> 1` to avoid division by zero.
- **`<li>`-based XPath generation**: When the current page starts on a
list item, `findXPathForProgress` now generates `ul[N]/li[M]` XPaths
rather than falling back to the preceding `<p>`. Requires the new
`listItemIndex` field in `PageLutEntry` (section cache version bumped to
23).
- **Text-node precision with correct `text()[N].M` format**: KOReader
expects `text()[N].M` where `N` is the 1-based index of the specific
text node within the element. The previous attempt generated `text().M`
(no brackets), which caused KOReader to jump to the front of the book.
Implements a per-element text-node index stack in
`XPathProgressResolver` — parallel to the existing element path stack —
that correctly tracks text node indices relative to each element. Empty
text nodes from bare anchor elements (`<a id="anchor"/>`) are
intentionally skipped, matching KOReader's own text node counting
behavior.

**Reviewer-caught bugs**
- **Double `onCloseTag()` on malformed `</br/>`**: Both the `tagIsClose`
path and the self-closing `/` check were firing, double-decrementing
`htmlDepth`. Fixed with a `!tagIsClose` guard.
- **Dangling pointer in `LOG_DBG`**:
`std::to_string(*nextParagraphPage).c_str()` passed a pointer to a
temporary destroyed before the variadic call. Fixed with `snprintf` into
a stack `char[8]` buffer.

## Additional Context

- Section cache version bumped from 22 → 23 due to the new
`listItemIndex` field in `PageLutEntry`. Users upgrading will see a
one-time re-render of all cached sections on first load — no data loss.
- The `textNodeIndexStack` in `XPathProgressResolver` is a
`std::vector<int>` that mirrors the existing `path` and `parentStates`
stacks — same depth, same lifetime. No additional heap pressure beyond
what was already present.
- All fixes verified on device with *Gentle and Lowly* by Dane C.
Ortlund (spine 21, 17 pages). Download syncs land on the correct page;
upload syncs land at the correct paragraph with character-level offset.

## Test plan

- [ ] Download: sync from KOReader → CrossPoint lands on correct page
for `text()[N].M` XPaths
- [ ] Download: ancestry correctly resolves `<li>` positions inbound
from KOReader
- [ ] Upload: sync from CrossPoint → KOReader lands within one page for
mid-paragraph positions
- [ ] Upload: sync from CrossPoint → KOReader correctly targets `<li>`
elements when page starts on a list item
- [ ] Upload: `text()[N].M` format XPaths do not cause KOReader to jump
to front of book
- [ ] Section cache version 23: delete `.crosspoint/` and verify clean
re-parse with no crashes

---

### AI Usage

Did you use AI tools to help write this code? **YES** — developed with
Claude Code (Anthropic).

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:22:24 -04:00

596 lines
16 KiB
C++

#include "ChapterXPathResolver.h"
#include <Logging.h>
#include <Print.h>
#include <Utf8.h>
#include <XmlParserUtils.h>
#include <expat.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
namespace {
std::string stripPrefix(const XML_Char* name) {
if (!name) {
return "";
}
const char* local = std::strrchr(name, ':');
return local ? std::string(local + 1) : std::string(name);
}
struct NameCounter {
std::string name;
int count;
};
struct ParentState {
std::vector<NameCounter> children;
int nextIndex(const std::string& name) {
for (auto& child : children) {
if (child.name == name) {
child.count++;
return child.count;
}
}
children.push_back({name, 1});
return 1;
}
};
struct PathSegment {
std::string name;
int index;
};
std::string buildParagraphXPath(const int spineIndex, const std::vector<PathSegment>& path, const int textNodeIndex,
const size_t charOffset) {
std::string xpath = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
for (const auto& segment : path) {
xpath += "/" + segment.name + "[" + std::to_string(segment.index) + "]";
}
if (textNodeIndex > 0 && charOffset > 0) {
xpath += "/text()[" + std::to_string(textNodeIndex) + "]." + std::to_string(charOffset);
}
return xpath;
}
size_t countUtf8Codepoints(const XML_Char* data, const int len) {
if (!data || len <= 0) {
return 0;
}
size_t count = 0;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(data);
const unsigned char* end = ptr + len;
while (ptr < end) {
utf8NextCodepoint(&ptr);
count++;
}
return count;
}
class ParagraphTextCounter final : public Print {
public:
ParagraphTextCounter() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
LOG_ERR("KOX", "Failed to create XML parser");
return;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, &ParagraphTextCounter::startElement, &ParagraphTextCounter::endElement);
XML_SetCharacterDataHandler(parser, &ParagraphTextCounter::characterData);
}
~ParagraphTextCounter() override { destroyXmlParser(parser); }
bool ok() const { return parser != nullptr && parseOk; }
bool finish() {
if (!parser || !parseOk || stopped) {
return parseOk;
}
if (XML_Parse(parser, "", 0, XML_TRUE) == XML_STATUS_ERROR) {
LOG_ERR("KOX", "Final XML parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
parseOk = false;
}
return parseOk;
}
size_t write(uint8_t c) override { return write(&c, 1); }
size_t write(const uint8_t* buffer, size_t size) override {
if (!parser || !parseOk || stopped) {
return size;
}
if (XML_Parse(parser, reinterpret_cast<const char*>(buffer), static_cast<int>(size), XML_FALSE) != XML_STATUS_OK) {
const enum XML_Error error = XML_GetErrorCode(parser);
if (error != XML_ERROR_ABORTED) {
LOG_ERR("KOX", "XML parse error: %s", XML_ErrorString(error));
parseOk = false;
}
}
return size;
}
size_t totalVisibleChars() const { return visibleChars; }
private:
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char**) {
auto* self = static_cast<ParagraphTextCounter*>(userData);
self->onStartElement(name);
}
static void XMLCALL endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ParagraphTextCounter*>(userData);
self->onEndElement(name);
}
static void XMLCALL characterData(void* userData, const XML_Char* data, const int len) {
auto* self = static_cast<ParagraphTextCounter*>(userData);
self->onCharacterData(data, len);
}
void onStartElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
if (!insideBody) {
if (name == "body") {
insideBody = true;
bodyDepth = depth;
}
depth++;
return;
}
if (name == "p") {
paragraphDepth++;
}
depth++;
}
void onEndElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
depth--;
if (!insideBody) {
return;
}
if (depth == bodyDepth && name == "body") {
insideBody = false;
return;
}
if (name == "p" && paragraphDepth > 0) {
paragraphDepth--;
}
}
void onCharacterData(const XML_Char* data, const int len) {
if (!insideBody || paragraphDepth <= 0 || len <= 0) {
return;
}
visibleChars += countUtf8Codepoints(data, len);
}
private:
XML_Parser parser = nullptr;
bool parseOk = true;
bool insideBody = false;
bool stopped = false;
int depth = 0;
int bodyDepth = -1;
int paragraphDepth = 0;
size_t visibleChars = 0;
};
class XPathParagraphResolver final : public Print {
public:
explicit XPathParagraphResolver(const int targetParagraph) : targetParagraph(targetParagraph) {
parser = XML_ParserCreate(nullptr);
if (!parser) {
LOG_ERR("KOX", "Failed to create XML parser");
return;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, &XPathParagraphResolver::startElement, &XPathParagraphResolver::endElement);
}
~XPathParagraphResolver() override { destroyXmlParser(parser); }
bool ok() const { return parser != nullptr && parseOk; }
bool finish() {
if (!parser || !parseOk || stopped) {
return parseOk;
}
if (XML_Parse(parser, "", 0, XML_TRUE) == XML_STATUS_ERROR) {
LOG_ERR("KOX", "Final XML parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
parseOk = false;
}
return parseOk;
}
bool hasMatch() const { return !xpath.empty(); }
const std::string& getXPath() const { return xpath; }
size_t write(uint8_t c) override { return write(&c, 1); }
size_t write(const uint8_t* buffer, size_t size) override {
if (!parser || !parseOk || stopped) {
return size;
}
if (XML_Parse(parser, reinterpret_cast<const char*>(buffer), static_cast<int>(size), XML_FALSE) != XML_STATUS_OK) {
const enum XML_Error error = XML_GetErrorCode(parser);
if (error != XML_ERROR_ABORTED) {
LOG_ERR("KOX", "XML parse error: %s", XML_ErrorString(error));
parseOk = false;
}
}
return size;
}
int spineIndex = 0;
private:
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char**) {
auto* self = static_cast<XPathParagraphResolver*>(userData);
self->onStartElement(name);
}
static void XMLCALL endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<XPathParagraphResolver*>(userData);
self->onEndElement(name);
}
void onStartElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
if (!insideBody) {
if (name == "body") {
insideBody = true;
bodyDepth = depth;
parentStates.emplace_back();
}
depth++;
return;
}
const int siblingIndex = parentStates.back().nextIndex(name);
path.push_back({name, siblingIndex});
parentStates.emplace_back();
if (name == "p") {
paragraphCount++;
if (paragraphCount == targetParagraph) {
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
stopped = true;
XML_StopParser(parser, XML_FALSE);
}
}
depth++;
}
void onEndElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
depth--;
if (!insideBody) {
return;
}
if (depth == bodyDepth && name == "body") {
insideBody = false;
parentStates.clear();
path.clear();
return;
}
if (!path.empty()) {
path.pop_back();
}
if (!parentStates.empty()) {
parentStates.pop_back();
}
}
XML_Parser parser = nullptr;
const int targetParagraph;
bool parseOk = true;
bool insideBody = false;
bool stopped = false;
int depth = 0;
int bodyDepth = -1;
int paragraphCount = 0;
std::vector<ParentState> parentStates;
std::vector<PathSegment> path;
std::string xpath;
};
class XPathProgressResolver final : public Print {
public:
explicit XPathProgressResolver(const size_t targetVisibleChar) : targetVisibleChar(targetVisibleChar) {
parser = XML_ParserCreate(nullptr);
if (!parser) {
LOG_ERR("KOX", "Failed to create XML parser");
return;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, &XPathProgressResolver::startElement, &XPathProgressResolver::endElement);
XML_SetCharacterDataHandler(parser, &XPathProgressResolver::characterData);
}
~XPathProgressResolver() override { destroyXmlParser(parser); }
bool ok() const { return parser != nullptr && parseOk; }
bool finish() {
if (!parser || !parseOk || stopped) {
return parseOk;
}
if (XML_Parse(parser, "", 0, XML_TRUE) == XML_STATUS_ERROR) {
LOG_ERR("KOX", "Final XML parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
parseOk = false;
}
return parseOk;
}
bool hasMatch() const { return !xpath.empty(); }
const std::string& getXPath() const { return xpath; }
size_t write(uint8_t c) override { return write(&c, 1); }
size_t write(const uint8_t* buffer, size_t size) override {
if (!parser || !parseOk || stopped) {
return size;
}
if (XML_Parse(parser, reinterpret_cast<const char*>(buffer), static_cast<int>(size), XML_FALSE) != XML_STATUS_OK) {
const enum XML_Error error = XML_GetErrorCode(parser);
if (error != XML_ERROR_ABORTED) {
LOG_ERR("KOX", "XML parse error: %s", XML_ErrorString(error));
parseOk = false;
}
}
return size;
}
int spineIndex = 0;
private:
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char**) {
auto* self = static_cast<XPathProgressResolver*>(userData);
self->onStartElement(name);
}
static void XMLCALL endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<XPathProgressResolver*>(userData);
self->onEndElement(name);
}
static void XMLCALL characterData(void* userData, const XML_Char* data, const int len) {
auto* self = static_cast<XPathProgressResolver*>(userData);
self->onCharacterData(data, len);
}
void onStartElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
if (!insideBody) {
if (name == "body") {
insideBody = true;
bodyDepth = depth;
parentStates.emplace_back();
}
depth++;
return;
}
const int siblingIndex = parentStates.back().nextIndex(name);
path.push_back({name, siblingIndex});
parentStates.emplace_back();
textNodeIndexStack.push_back(0);
pendingTextNode = true;
if (name == "p") {
paragraphDepth++;
}
if (name == "li") {
liDepth++;
}
depth++;
}
void onEndElement(const XML_Char* rawName) {
const std::string name = stripPrefix(rawName);
depth--;
if (!insideBody) {
return;
}
if (depth == bodyDepth && name == "body") {
insideBody = false;
parentStates.clear();
path.clear();
textNodeIndexStack.clear();
return;
}
if (name == "p" && paragraphDepth > 0) {
paragraphDepth--;
}
if (name == "li" && liDepth > 0) {
liDepth--;
}
if (!textNodeIndexStack.empty()) {
textNodeIndexStack.pop_back();
}
if (paragraphDepth > 0 || liDepth > 0) {
pendingTextNode = true;
}
if (!path.empty()) {
path.pop_back();
}
if (!parentStates.empty()) {
parentStates.pop_back();
}
}
void onCharacterData(const XML_Char* data, const int len) {
if (!insideBody || (paragraphDepth <= 0 && liDepth <= 0) || len <= 0 || stopped) {
return;
}
const size_t codepointCount = countUtf8Codepoints(data, len);
if (codepointCount == 0) {
return;
}
// Start a new text node on first non-empty content after any element boundary.
// Only counting non-empty nodes matches KOReader's text()[N] indexing behavior,
// which skips empty text nodes created by bare <a id="anchor"/> anchors.
if (pendingTextNode) {
if (!textNodeIndexStack.empty()) {
textNodeIndexStack.back()++;
}
textNodeStartChars = visibleChars;
pendingTextNode = false;
}
const size_t nextVisibleChars = visibleChars + codepointCount;
if (targetVisibleChar <= nextVisibleChars) {
const size_t delta = targetVisibleChar - visibleChars;
const int texNode = textNodeIndexStack.empty() ? 0 : textNodeIndexStack.back();
const size_t charOff = visibleChars - textNodeStartChars + delta;
xpath = buildParagraphXPath(spineIndex, path, texNode, charOff);
stopped = true;
XML_StopParser(parser, XML_FALSE);
return;
}
visibleChars = nextVisibleChars;
}
XML_Parser parser = nullptr;
const size_t targetVisibleChar;
bool parseOk = true;
bool insideBody = false;
bool stopped = false;
bool pendingTextNode = true;
int depth = 0;
int bodyDepth = -1;
int paragraphDepth = 0;
int liDepth = 0;
size_t visibleChars = 0;
size_t textNodeStartChars = 0;
std::vector<int> textNodeIndexStack;
std::vector<ParentState> parentStates;
std::vector<PathSegment> path;
std::string xpath;
};
} // namespace
std::string ChapterXPathResolver::findXPathForParagraph(const std::shared_ptr<Epub>& epub, const int spineIndex,
const uint16_t paragraphIndex) {
if (!epub || paragraphIndex == 0 || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return "";
}
const auto href = epub->getSpineItem(spineIndex).href;
if (href.empty()) {
return "";
}
XPathParagraphResolver resolver(paragraphIndex);
if (!resolver.ok()) {
return "";
}
resolver.spineIndex = spineIndex;
if (!epub->readItemContentsToStream(href, resolver, 1024) || !resolver.finish()) {
return "";
}
if (resolver.hasMatch()) {
LOG_DBG("KOX", "Resolved paragraph %u in spine %d -> %s", paragraphIndex, spineIndex, resolver.getXPath().c_str());
return resolver.getXPath();
}
LOG_DBG("KOX", "Paragraph %u not found in spine %d", paragraphIndex, spineIndex);
return "";
}
std::string ChapterXPathResolver::findXPathForProgress(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return "";
}
const auto href = epub->getSpineItem(spineIndex).href;
if (href.empty()) {
return "";
}
if (!(intraSpineProgress > 0.0f)) {
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
}
ParagraphTextCounter counter;
if (!counter.ok() || !epub->readItemContentsToStream(href, counter, 1024) || !counter.finish()) {
return "";
}
const size_t totalVisibleChars = counter.totalVisibleChars();
if (totalVisibleChars == 0) {
return "";
}
const float clamped = std::max(0.0f, std::min(1.0f, intraSpineProgress));
const size_t targetVisibleChar =
std::max<size_t>(1, std::min(totalVisibleChars, static_cast<size_t>(std::ceil(clamped * totalVisibleChars))));
XPathProgressResolver resolver(targetVisibleChar);
if (!resolver.ok()) {
return "";
}
resolver.spineIndex = spineIndex;
if (!epub->readItemContentsToStream(href, resolver, 1024) || !resolver.finish()) {
return "";
}
if (resolver.hasMatch()) {
LOG_DBG("KOX", "Resolved progress %.3f in spine %d -> %s", intraSpineProgress, spineIndex,
resolver.getXPath().c_str());
return resolver.getXPath();
}
LOG_DBG("KOX", "Could not resolve progress %.3f in spine %d", intraSpineProgress, spineIndex);
return "";
}