Files
Crosspoint/lib/KOReaderSync/BookXPath.cpp
T
Justin Mitchell d364e54a69 Add KOReader XPath sync support for EPUB navigation
Implements bidirectional conversion between KOReader XPath positions and FreeInkBook character offsets. Uses streaming SAX parsing to match chapter text accounting with ChapterLayout, enabling position sync without requiring DOM or pagination. Handles element ancestry tracking, text offset calculation, and spine index extraction.
2026-07-08 01:11:29 -04:00

367 lines
12 KiB
C++

#include "BookXPath.h"
#include <Logging.h>
#include <Memory.h>
#include <epub/XmlSax.h>
#include <cstdio>
#include <cstring>
namespace {
using freeink::book::Arena;
using freeink::book::BookStatus;
using freeink::book::XmlHandler;
using freeink::book::XmlSax;
// XmlSax parse working set: inflate window + parse chunk (+ expat's own
// bounded heap). One transient allocation per mapping call.
constexpr size_t kParseScratchSize = 64 * 1024;
constexpr int kMaxDepth = 24; // element ancestry below <body>
constexpr int kMaxSiblingTags = 24; // distinct child tag names tracked per level
constexpr int kMaxSteps = 16; // parsed xpath ancestry steps
const char* localName(const char* qname) {
const char* colon = strrchr(qname, ':');
return colon != nullptr ? colon + 1 : qname;
}
bool isSuppressedElement(const char* local) {
return strcmp(local, "head") == 0 || strcmp(local, "style") == 0 || strcmp(local, "script") == 0 ||
strcmp(local, "title") == 0;
}
// ChapterLayout's block list — these flush the paragraph accumulator.
bool isBlockElement(const char* local) {
static const char* kBlocks[] = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
"li", "div", "section", "article", "figure", "aside", "figcaption", "ul",
"ol", "table", "tr", "td", "th", "dt", "dd"};
for (const char* b : kBlocks) {
if (strcmp(local, b) == 0) return true;
}
return false;
}
uint32_t fnvHash(const char* s) {
uint32_t hash = 2166136261u;
while (*s != '\0') {
hash ^= static_cast<uint8_t>(*s++);
hash *= 16777619u;
}
return hash;
}
struct AncestryLevel {
char tag[16];
int siblingIndex; // 1-based, among same-tag siblings
// Child tag occurrence counts (for the level BELOW this one).
uint32_t childTagHashes[kMaxSiblingTags];
int childTagCounts[kMaxSiblingTags];
int childTagKinds;
};
struct XPathStep {
char tag[16];
int siblingIndex;
};
// Parses the element steps between "]/body/" and the terminal "/text()..."
// or ".NN" of a KOReader xpath. Returns the step count, 0 on failure.
int parseXPathSteps(const std::string& xpath, XPathStep* steps, int* charOffsetOut) {
*charOffsetOut = 0;
static const char kFrag[] = "/body/DocFragment[";
const size_t fragPos = xpath.find(kFrag);
if (fragPos == std::string::npos) return 0;
const size_t closeBracket = xpath.find(']', fragPos + strlen(kFrag));
if (closeBracket == std::string::npos) return 0;
static const char kBody[] = "/body/";
if (xpath.compare(closeBracket + 1, strlen(kBody), kBody) != 0) return 0;
size_t pos = closeBracket + 1 + strlen(kBody);
// Terminal: "/text()[K].N" or "/text().N" or ".N" directly on the element.
size_t stepsEnd = xpath.rfind("/text()");
if (stepsEnd == std::string::npos || stepsEnd < pos) {
const size_t dot = xpath.rfind('.');
stepsEnd = (dot != std::string::npos && dot > pos) ? dot : xpath.size();
}
const size_t dot = xpath.rfind('.');
if (dot != std::string::npos && dot + 1 < xpath.size()) {
int val = 0;
bool numeric = true;
for (size_t i = dot + 1; i < xpath.size(); ++i) {
if (xpath[i] < '0' || xpath[i] > '9') {
numeric = false;
break;
}
val = val * 10 + (xpath[i] - '0');
}
if (numeric) *charOffsetOut = val;
}
if (stepsEnd <= pos) return 0;
int count = 0;
while (pos < stepsEnd && count < kMaxSteps) {
const size_t slash = xpath.find('/', pos);
const size_t segEnd = (slash != std::string::npos && slash < stepsEnd) ? slash : stepsEnd;
XPathStep& step = steps[count];
const size_t bracket = xpath.find('[', pos);
const size_t nameEnd = (bracket != std::string::npos && bracket < segEnd) ? bracket : segEnd;
const size_t nameLen = nameEnd - pos;
if (nameLen == 0 || nameLen >= sizeof(step.tag)) return 0;
memcpy(step.tag, xpath.c_str() + pos, nameLen);
step.tag[nameLen] = '\0';
step.siblingIndex = 1;
if (bracket != std::string::npos && bracket < segEnd) {
const size_t close = xpath.find(']', bracket + 1);
if (close == std::string::npos || close > segEnd) return 0;
int idx = 0;
for (size_t i = bracket + 1; i < close; ++i) {
if (xpath[i] < '0' || xpath[i] > '9') return 0;
idx = idx * 10 + (xpath[i] - '0');
}
step.siblingIndex = idx > 0 ? idx : 1;
}
++count;
pos = (slash != std::string::npos && slash < stepsEnd) ? slash + 1 : stepsEnd;
}
return count;
}
// Streams the chapter, replicating ChapterLayout's extracted-text accounting
// (which defines charStart) while tracking DOM element ancestry with per-tag
// sibling indices. Runs in one of two modes:
// Locate: find the ancestry containing character offset `target`
// Resolve: find the character offset where a given ancestry begins
class XPathScanner final : public XmlHandler {
public:
enum class Mode { Locate, Resolve };
XPathScanner(const Mode mode, const uint32_t targetChar, const XPathStep* steps, const int stepCount)
: mode_(mode), targetChar_(targetChar), steps_(steps), stepCount_(stepCount) {
memset(&root_, 0, sizeof(root_));
}
void onStartElement(const char* name, const char** /*atts*/) override {
const char* local = localName(name);
if (isSuppressedElement(local)) {
++suppress_;
return;
}
if (strcmp(local, "body") == 0) {
inBody_ = true;
return;
}
if (!inBody_) return;
// Ancestry: sibling index among same-tag children of the current parent.
if (depth_ < kMaxDepth) {
AncestryLevel& parent = depth_ == 0 ? root_ : stack_[depth_ - 1];
AncestryLevel& self = stack_[depth_];
snprintf(self.tag, sizeof(self.tag), "%s", local);
self.siblingIndex = bumpChildCount(parent, local);
self.childTagKinds = 0;
}
++depth_;
if (suppress_ > 0) return;
if (isBlockElement(local)) {
flushParagraph();
} else if (strcmp(local, "br") == 0) {
++parChars_; // appendRaw('\n') — bypasses whitespace collapse
checkLocate();
} else if (strcmp(local, "hr") == 0 || strcmp(local, "img") == 0 || strcmp(local, "image") == 0) {
flushParagraph();
}
if (mode_ == Mode::Resolve && !resolved_ && depth_ <= kMaxDepth && depth_ == stepCount_ && ancestryMatchesSteps()) {
// Element start + the xpath's text offset. A pending collapsed space
// materializes before this element's first character, so it counts.
resolvedChar_ = charBase_ + parChars_ + (pendingSpace_ ? 1u : 0u) + targetChar_;
resolved_ = true;
stopParse = true;
}
}
void onEndElement(const char* name) override {
const char* local = localName(name);
if (isSuppressedElement(local)) {
if (suppress_ > 0) --suppress_;
return;
}
if (strcmp(local, "body") == 0) {
flushParagraph();
inBody_ = false;
return;
}
if (!inBody_) return;
if (depth_ > 0) --depth_;
if (suppress_ == 0 && isBlockElement(local)) flushParagraph();
}
void onText(const char* text, const int len) override {
if (!inBody_ || suppress_ > 0 || stopParse) return;
for (int i = 0; i < len; ++i) {
const char c = text[i];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
pendingSpace_ = parChars_ > 0;
} else {
if (pendingSpace_) {
++parChars_; // the collapsed space materializes
pendingSpace_ = false;
checkLocate();
if (stopParse) return;
}
if ((static_cast<uint8_t>(c) & 0xC0) != 0x80) {
++parChars_; // one codepoint (lead or ASCII byte)
checkLocate();
if (stopParse) return;
}
}
}
}
bool located() const { return located_; }
bool resolved() const { return resolved_; }
uint32_t resolvedChar() const { return resolvedChar_; }
uint32_t locatedOffset() const { return locatedOffset_; }
int locatedDepth() const { return locatedDepth_; }
const AncestryLevel* locatedStack() const { return locatedStack_; }
private:
int bumpChildCount(AncestryLevel& parent, const char* tag) {
const uint32_t hash = fnvHash(tag);
for (int i = 0; i < parent.childTagKinds; ++i) {
if (parent.childTagHashes[i] == hash) return ++parent.childTagCounts[i];
}
if (parent.childTagKinds < kMaxSiblingTags) {
parent.childTagHashes[parent.childTagKinds] = hash;
parent.childTagCounts[parent.childTagKinds] = 1;
++parent.childTagKinds;
return 1;
}
return 1; // tag-table overflow: index degrades to 1 (rare, deep soup)
}
void flushParagraph() {
charBase_ += parChars_;
parChars_ = 0;
pendingSpace_ = false;
}
// Locate mode: the character at index `targetChar_` was just appended —
// capture the current ancestry and the offset within this paragraph.
void checkLocate() {
if (mode_ != Mode::Locate || located_) return;
if (charBase_ + parChars_ > targetChar_) {
locatedDepth_ = depth_ <= kMaxDepth ? depth_ : kMaxDepth;
memcpy(locatedStack_, stack_, sizeof(AncestryLevel) * locatedDepth_);
locatedOffset_ = targetChar_ >= charBase_ ? targetChar_ - charBase_ : 0;
located_ = true;
stopParse = true;
}
}
bool ancestryMatchesSteps() const {
for (int i = 0; i < stepCount_; ++i) {
if (strcmp(stack_[i].tag, steps_[i].tag) != 0 || stack_[i].siblingIndex != steps_[i].siblingIndex) {
return false;
}
}
return true;
}
const Mode mode_;
const uint32_t targetChar_;
const XPathStep* steps_;
const int stepCount_;
AncestryLevel root_;
AncestryLevel stack_[kMaxDepth];
int depth_ = 0;
int suppress_ = 0;
bool inBody_ = false;
uint32_t charBase_ = 0;
uint32_t parChars_ = 0;
bool pendingSpace_ = false;
bool located_ = false;
uint32_t locatedOffset_ = 0;
int locatedDepth_ = 0;
AncestryLevel locatedStack_[kMaxDepth];
bool resolved_ = false;
uint32_t resolvedChar_ = 0;
};
bool runScan(freeink::book::BookSource& source, const freeink::book::ZipEntry& entry, XPathScanner& scanner) {
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kParseScratchSize);
if (!scratchBuf) {
LOG_ERR("KOXP", "OOM: xpath scan scratch (%u B)", static_cast<unsigned>(kParseScratchSize));
return false;
}
Arena scratch(scratchBuf.get(), kParseScratchSize);
const BookStatus st = XmlSax::parseEntry(source, entry, scratch, scanner, /*filterHtmlEntities=*/true);
return st == BookStatus::Ok;
}
} // namespace
namespace BookXPath {
std::string xpathForCharStart(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/,
const freeink::book::ZipEntry& entry, const int spineIndex, const uint32_t charStart) {
const std::string chapterOnly = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]";
XPathScanner scanner(XPathScanner::Mode::Locate, charStart, nullptr, 0);
if (!runScan(source, entry, scanner) || !scanner.located()) {
return chapterOnly;
}
std::string xpath = chapterOnly + "/body";
const AncestryLevel* stack = scanner.locatedStack();
for (int i = 0; i < scanner.locatedDepth(); ++i) {
xpath += "/";
xpath += stack[i].tag;
xpath += "[" + std::to_string(stack[i].siblingIndex) + "]";
}
xpath += "/text()." + std::to_string(scanner.locatedOffset());
return xpath;
}
bool charStartForXpath(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/,
const freeink::book::ZipEntry& entry, const std::string& xpath, uint32_t* charStartOut) {
*charStartOut = 0;
XPathStep steps[kMaxSteps];
int charOffset = 0;
const int stepCount = parseXPathSteps(xpath, steps, &charOffset);
if (stepCount == 0) {
// "/body/DocFragment[N]" with no element steps = chapter start.
return spineIndexForXpath(xpath) >= 0;
}
XPathScanner scanner(XPathScanner::Mode::Resolve, static_cast<uint32_t>(charOffset), steps, stepCount);
if (!runScan(source, entry, scanner) || !scanner.resolved()) {
return false;
}
*charStartOut = scanner.resolvedChar();
return true;
}
int spineIndexForXpath(const std::string& xpath) {
static const char kFrag[] = "/body/DocFragment[";
const size_t pos = xpath.find(kFrag);
if (pos == std::string::npos) return -1;
int val = 0;
bool any = false;
for (size_t i = pos + strlen(kFrag); i < xpath.size() && xpath[i] != ']'; ++i) {
if (xpath[i] < '0' || xpath[i] > '9') return -1;
val = val * 10 + (xpath[i] - '0');
any = true;
}
return any && val > 0 ? val - 1 : -1;
}
} // namespace BookXPath