Fix OOM
This commit is contained in:
@@ -8,190 +8,155 @@
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// Anchor used for both mapping directions.
|
||||
// textOffset is counted as visible (non-whitespace) bytes from chapter start.
|
||||
// xpath points to the nearest element path at/near that offset.
|
||||
// ---- 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<char>(std::tolower(static_cast<unsigned char>(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<unsigned char>(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<unsigned char>(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<unsigned char>(c);
|
||||
if (std::isspace(uc)) {
|
||||
continue;
|
||||
}
|
||||
out.push_back(static_cast<char>(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<StackNode> stack;
|
||||
std::vector<std::unordered_map<std::string, int>> siblingCounters;
|
||||
std::vector<XPathAnchor> anchors;
|
||||
|
||||
std::string baseXPath() const { return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; }
|
||||
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<unsigned char>(c);
|
||||
if (std::isspace(uc)) {
|
||||
continue;
|
||||
}
|
||||
out.push_back(static_cast<char>(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<int>(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<int>(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<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Elements that should not contribute text position anchors.
|
||||
static bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; }
|
||||
|
||||
static bool isWhitespaceOnly(const XML_Char* text, const int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Count non-whitespace bytes to keep offsets stable against formatting-only
|
||||
// differences and indentation in source XHTML.
|
||||
static size_t countVisibleBytes(const XML_Char* text, const int len) {
|
||||
size_t count = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int bodyDepth() const {
|
||||
int bodyIdx() const {
|
||||
for (int i = static_cast<int>(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<size_t>(bodyIdx + 1); i < stack.size(); i++) {
|
||||
for (size_t i = static_cast<size_t>(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<int>(stack.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void onEndElement() {
|
||||
if (stack.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipDepth == static_cast<int>(stack.size()) - 1) {
|
||||
skipDepth = -1;
|
||||
}
|
||||
|
||||
stack.pop_back();
|
||||
if (!siblingCounters.empty()) {
|
||||
siblingCounters.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
void onCharacterData(const XML_Char* text, const int len) {
|
||||
if (skipDepth >= 0 || len <= 0 || !insideBody() || isWhitespaceOnly(text, len)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addAnchorIfNeeded();
|
||||
totalTextBytes += countVisibleBytes(text, len);
|
||||
}
|
||||
|
||||
std::string chooseXPath(const float intraSpineProgress) const {
|
||||
if (anchors.empty()) {
|
||||
return baseXPath();
|
||||
}
|
||||
if (totalTextBytes == 0) {
|
||||
return anchors.front().xpath;
|
||||
}
|
||||
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
||||
const size_t target = static_cast<size_t>(clampedProgress * static_cast<float>(totalTextBytes));
|
||||
|
||||
// upper_bound returns the first anchor strictly after target; step back to get
|
||||
// the last anchor at-or-before target (the element the user is currently inside).
|
||||
auto it = std::upper_bound(anchors.begin(), anchors.end(), target,
|
||||
[](const size_t value, const XPathAnchor& anchor) { return value < anchor.textOffset; });
|
||||
if (it != anchors.begin()) {
|
||||
--it;
|
||||
}
|
||||
return it->xpath;
|
||||
}
|
||||
|
||||
// Convert path -> progress ratio by matching to nearest available anchor.
|
||||
bool chooseProgressForXPath(const std::string& xpath, float& outIntraSpineProgress, bool& outExactMatch) const {
|
||||
if (anchors.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string normalized = normalizeXPath(xpath);
|
||||
if (normalized.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t matchedOffset = 0;
|
||||
bool exact = false;
|
||||
const char* matchTier = nullptr;
|
||||
|
||||
bool matched = pickBestAnchorByPath(normalized, false, matchedOffset, exact);
|
||||
if (matched) {
|
||||
matchTier = exact ? "exact" : "ancestor";
|
||||
} else {
|
||||
bool exactRaw = false;
|
||||
matched = pickBestAnchorByPath(normalized, true, matchedOffset, exactRaw);
|
||||
if (matched) {
|
||||
exact = false;
|
||||
matchTier = exactRaw ? "index-insensitive" : "index-insensitive-ancestor";
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
LOG_DBG("KOX", "Reverse: spine=%d no anchor match for '%s' (%zu anchors)", spineIndex, normalized.c_str(),
|
||||
anchors.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
outExactMatch = exact;
|
||||
if (totalTextBytes == 0) {
|
||||
outIntraSpineProgress = 0.0f;
|
||||
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu -> progress=0.0 (no text)", spineIndex, matchTier,
|
||||
matchedOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
outIntraSpineProgress = static_cast<float>(matchedOffset) / static_cast<float>(totalTextBytes);
|
||||
outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress));
|
||||
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f", spineIndex, matchTier, matchedOffset,
|
||||
totalTextBytes, outIntraSpineProgress);
|
||||
return true;
|
||||
}
|
||||
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<ParserState*>(userData);
|
||||
state->onStartElement(name);
|
||||
}
|
||||
// ---- Decompress spine item to temp file ----
|
||||
|
||||
void XMLCALL onEndElement(void* userData, const XML_Char*) {
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onEndElement();
|
||||
}
|
||||
|
||||
void XMLCALL onCharacterData(void* userData, const XML_Char* text, const int len) {
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onCharacterData(text, len);
|
||||
}
|
||||
|
||||
void XMLCALL onDefaultHandlerExpand(void* userData, const XML_Char* text, const int len) {
|
||||
// The default handler fires for comments, PIs, DOCTYPE, and entity references.
|
||||
// Only forward entity references (&..;) to avoid skewing text offsets with
|
||||
// non-visible markup.
|
||||
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
|
||||
return;
|
||||
}
|
||||
for (int i = 1; i < len - 1; ++i) {
|
||||
if (text[i] == '<' || text[i] == '>') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onCharacterData(text, len);
|
||||
}
|
||||
|
||||
// Parse one spine item and return a fully populated ParserState.
|
||||
// Returns std::nullopt if validation, I/O, or XML parse fails.
|
||||
static std::optional<ParserState> parseSpineItem(const std::shared_ptr<Epub>& epub, const int spineIndex) {
|
||||
std::string decompressToTempFile(const std::shared_ptr<Epub>& 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<int>(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<int>(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<ByteCounter*>(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<ByteCounter*>(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<ByteCounter*>(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<ForwardState*>(ud)->pushElement(name);
|
||||
}
|
||||
|
||||
void XMLCALL fwdEnd(void* ud, const XML_Char*) { static_cast<ForwardState*>(ud)->popElement(); }
|
||||
|
||||
void XMLCALL fwdChar(void* ud, const XML_Char* text, const int len) {
|
||||
static_cast<ForwardState*>(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<ReverseState*>(ud)->pushElement(name);
|
||||
}
|
||||
|
||||
void XMLCALL revEnd(void* ud, const XML_Char*) { static_cast<ReverseState*>(ud)->popElement(); }
|
||||
|
||||
void XMLCALL revChar(void* ud, const XML_Char* text, const int len) {
|
||||
static_cast<ReverseState*>(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>& 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<size_t>(clamped * static_cast<float>(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>& epub, const int spineIndex,
|
||||
@@ -488,14 +522,47 @@ bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& 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<float>(state.bestOffset) / static_cast<float>(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) {
|
||||
|
||||
Reference in New Issue
Block a user