Extract book cover utilities into BookCoverUtils
Refactors book cover and metadata handling out of Epub class into a dedicated BookCoverUtils utility. Moves FootnoteEntry struct into reader activity directory. Simplifies cache clearing to use direct storage operations instead of Epub class.
This commit is contained in:
@@ -1,600 +0,0 @@
|
||||
#include "ChapterXPathResolver.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <Print.h>
|
||||
#include <Utf8.h>
|
||||
#include <XmlParserUtils.h>
|
||||
#include <epub/Expat.h> // FreeInkBook's vendored expat
|
||||
|
||||
#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();
|
||||
|
||||
// Count both <p> and <li> as paragraph-like positions, matching how the section
|
||||
// layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures
|
||||
// KOReader progress in list items maps to the correct XPath.
|
||||
if (name == "p") {
|
||||
paragraphCount++;
|
||||
} else if (name == "li") {
|
||||
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 "";
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Epub.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
class ChapterXPathResolver {
|
||||
public:
|
||||
/**
|
||||
* Resolve the Nth paragraph in a spine item to its real XHTML ancestry path.
|
||||
*
|
||||
* Returns a KOReader-compatible path like:
|
||||
* /body/DocFragment[8]/body/div[2]/section[1]/p[4]
|
||||
*
|
||||
* An empty string means parsing failed or the paragraph index was not found.
|
||||
*/
|
||||
static std::string findXPathForParagraph(const std::shared_ptr<Epub>& epub, int spineIndex, uint16_t paragraphIndex);
|
||||
|
||||
/**
|
||||
* Resolve intra-spine progress to a real XHTML ancestry path plus text offset.
|
||||
*
|
||||
* Returns a KOReader-compatible path like:
|
||||
* /body/DocFragment[8]/body/div[2]/section[1]/p[4]/text().96
|
||||
*
|
||||
* An empty string means parsing failed or the location could not be resolved.
|
||||
*/
|
||||
static std::string findXPathForProgress(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
|
||||
};
|
||||
@@ -1,903 +0,0 @@
|
||||
#include "ProgressMapper.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include "ChapterXPathResolver.h"
|
||||
#include "Epub/Section.h"
|
||||
#include "Epub/htmlEntities.h"
|
||||
#include "Utf8.h"
|
||||
|
||||
namespace {
|
||||
int parseIndex(const std::string& xpath, const char* prefix, bool last = false) {
|
||||
const size_t prefixLen = strlen(prefix);
|
||||
const size_t pos = last ? xpath.rfind(prefix) : xpath.find(prefix);
|
||||
if (pos == std::string::npos) return -1;
|
||||
const size_t numStart = pos + prefixLen;
|
||||
const size_t numEnd = xpath.find(']', numStart);
|
||||
if (numEnd == std::string::npos || numEnd == numStart) return -1;
|
||||
int val = 0;
|
||||
for (size_t i = numStart; i < numEnd; i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return -1;
|
||||
val = val * 10 + (xpath[i] - '0');
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
int parseCharOffset(const std::string& xpath) {
|
||||
const size_t textPos = xpath.rfind("text()");
|
||||
const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.');
|
||||
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0;
|
||||
int val = 0;
|
||||
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 0;
|
||||
val = val * 10 + (xpath[i] - '0');
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// Parse the N from text()[N] in the XPath (1-based; defaults to 1 if absent or 1).
|
||||
int parseTextNodeIndex(const std::string& xpath) {
|
||||
const size_t textPos = xpath.rfind("text()[");
|
||||
if (textPos == std::string::npos) return 1;
|
||||
const size_t numStart = textPos + 7; // strlen("text()[")
|
||||
const size_t numEnd = xpath.find(']', numStart);
|
||||
if (numEnd == std::string::npos || numEnd == numStart) return 1;
|
||||
int val = 0;
|
||||
for (size_t i = numStart; i < numEnd; i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 1;
|
||||
val = val * 10 + (xpath[i] - '0');
|
||||
}
|
||||
return val > 0 ? val : 1;
|
||||
}
|
||||
|
||||
bool isChapterStartXPath(const std::string& xpath) {
|
||||
if (xpath.find("/p[") != std::string::npos || xpath.find("/li[") != std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static constexpr char kDocFragment[] = "/body/DocFragment[";
|
||||
const size_t docFragPos = xpath.find(kDocFragment);
|
||||
if (docFragPos == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
const size_t docFragEnd = xpath.find(']', docFragPos + strlen(kDocFragment));
|
||||
if (docFragEnd == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
if (docFragEnd + 1 == xpath.size()) {
|
||||
return true;
|
||||
}
|
||||
if (xpath[docFragEnd + 1] == '.') {
|
||||
if (docFragEnd + 2 >= xpath.size()) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = docFragEnd + 2; i < xpath.size(); i++) {
|
||||
if (xpath[i] != '0') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static constexpr char kDocBody[] = "]/body";
|
||||
const size_t docBodyPos = xpath.find(kDocBody);
|
||||
if (docBodyPos == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
size_t bodyContentStart = docBodyPos + strlen(kDocBody);
|
||||
if (bodyContentStart == xpath.size()) {
|
||||
return true;
|
||||
}
|
||||
if (xpath[bodyContentStart] != '/') {
|
||||
return false;
|
||||
}
|
||||
bodyContentStart++;
|
||||
if (bodyContentStart == xpath.size()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t dotPos = xpath.rfind('.');
|
||||
if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) {
|
||||
return false;
|
||||
}
|
||||
size_t terminalEnd = dotPos;
|
||||
static constexpr char kTextNode[] = "/text()";
|
||||
const size_t textNodePos = xpath.rfind(kTextNode, dotPos);
|
||||
if (textNodePos != std::string::npos && textNodePos >= bodyContentStart) {
|
||||
terminalEnd = textNodePos;
|
||||
}
|
||||
if (xpath.find('/', bodyContentStart) < terminalEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
|
||||
if (xpath[i] != '0') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parsed representation of one step in the XPath ancestry.
|
||||
struct XPathStep {
|
||||
char tag[12]; // element name, null-terminated
|
||||
int siblingIndex; // 1-based sibling index, or 0 if unspecified (treat as 1)
|
||||
};
|
||||
|
||||
static constexpr int MAX_XPATH_DEPTH = 16;
|
||||
|
||||
// Parse the XPath segment between /body/DocFragment[N]/body/ and the terminal position
|
||||
// into an ordered sequence of steps. Returns step count, 0 on failure.
|
||||
// Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51"
|
||||
// Fills steps with: {div,1}, {ul,1}, {li,4}
|
||||
int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH]) {
|
||||
static const char kBodyFrag[] = "/body/DocFragment[";
|
||||
const size_t fragPos = xpath.find(kBodyFrag);
|
||||
if (fragPos == std::string::npos) return 0;
|
||||
const size_t afterBracket = xpath.find(']', fragPos + strlen(kBodyFrag));
|
||||
if (afterBracket == std::string::npos) return 0;
|
||||
static const char kBody[] = "/body/";
|
||||
if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0;
|
||||
size_t pos = afterBracket + 1 + strlen(kBody);
|
||||
|
||||
size_t stepsEnd = xpath.rfind("/text()");
|
||||
if (stepsEnd == std::string::npos) {
|
||||
stepsEnd = xpath.rfind('.');
|
||||
if (stepsEnd == std::string::npos || stepsEnd <= pos || stepsEnd + 1 >= xpath.size()) return 0;
|
||||
for (size_t i = stepsEnd + 1; i < xpath.size(); i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 0;
|
||||
}
|
||||
}
|
||||
if (stepsEnd <= pos) return 0;
|
||||
|
||||
int count = 0;
|
||||
while (pos < stepsEnd && count < MAX_XPATH_DEPTH) {
|
||||
const size_t slash = xpath.find('/', pos);
|
||||
const size_t segEnd = (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';
|
||||
|
||||
if (bracket != std::string::npos && bracket < segEnd) {
|
||||
const size_t closeBracket = xpath.find(']', bracket + 1);
|
||||
if (closeBracket == std::string::npos || closeBracket > segEnd) return 0;
|
||||
int idx = 0;
|
||||
for (size_t i = bracket + 1; i < closeBracket; i++) {
|
||||
if (xpath[i] < '0' || xpath[i] > '9') return 0;
|
||||
idx = idx * 10 + (xpath[i] - '0');
|
||||
}
|
||||
step.siblingIndex = idx;
|
||||
} else {
|
||||
step.siblingIndex = 1;
|
||||
}
|
||||
|
||||
count++;
|
||||
pos = (slash < stepsEnd) ? slash + 1 : stepsEnd;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class ParagraphStreamer final : public Print {
|
||||
size_t bytesWritten = 0;
|
||||
bool globalInTag = false;
|
||||
bool globalInEntity = false;
|
||||
static constexpr size_t MAX_ENTITY_SIZE = 16;
|
||||
char entityBuffer[MAX_ENTITY_SIZE] = {};
|
||||
size_t entityLen = 0;
|
||||
|
||||
// Forward mode: count <p> paragraphs at a byte offset (legacy, used by generateXPath)
|
||||
size_t fwdTarget;
|
||||
int fwdResult = 0;
|
||||
bool fwdCaptured = false;
|
||||
|
||||
// Reverse mode shared state
|
||||
int revChar;
|
||||
bool revPFound = false;
|
||||
bool revDone = false;
|
||||
int revVisChars = 0;
|
||||
size_t totalVisChars = 0;
|
||||
size_t targetVisChars = 0;
|
||||
|
||||
// --- Legacy reverse mode (paragraph index only, no ancestry) ---
|
||||
int revParagraph = 0;
|
||||
int pCount = 0;
|
||||
int paragraphAtMatch = 0;
|
||||
int liCount = 0;
|
||||
int liCountAtMatch = 0;
|
||||
int targetTextNode = 1;
|
||||
int currentTextNode = 0;
|
||||
int paragraphHtmlDepth = -1;
|
||||
|
||||
// --- Ancestry-aware reverse mode ---
|
||||
const XPathStep* steps = nullptr;
|
||||
int stepCount = 0;
|
||||
int siblingCounters[MAX_XPATH_DEPTH] = {};
|
||||
bool insideStep[MAX_XPATH_DEPTH] = {};
|
||||
int htmlDepth = 0;
|
||||
int stepEnteredAtDepth[MAX_XPATH_DEPTH] = {};
|
||||
|
||||
// Tag name accumulation
|
||||
enum TagParseState { TAG_IDLE, TAG_IN_NAME, TAG_ATTRS } tagState = TAG_IDLE;
|
||||
bool tagIsClose = false;
|
||||
char tagName[12] = {};
|
||||
int tagNameLen = 0;
|
||||
|
||||
int matchedDepth = 0;
|
||||
|
||||
// Anchor ID capture
|
||||
static constexpr int MAX_ANCHOR_ID = 64;
|
||||
char capturedAnchorId[MAX_ANCHOR_ID] = {};
|
||||
int capturedAnchorIdLen = 0;
|
||||
bool capturingAnchorTag = false;
|
||||
enum AnchorAttrState {
|
||||
ATTR_FIND_NAME,
|
||||
ATTR_READ_NAME,
|
||||
ATTR_AFTER_NAME,
|
||||
ATTR_BEFORE_VALUE,
|
||||
ATTR_CAPTURE_D,
|
||||
ATTR_CAPTURE_S
|
||||
} attrState = ATTR_FIND_NAME;
|
||||
uint8_t attrNameLen = 0;
|
||||
bool currentAttrIsId = false;
|
||||
bool inAttrQuote =
|
||||
false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close)
|
||||
char attrQuoteChar = 0;
|
||||
uint8_t nonVisibleDepth = 0;
|
||||
|
||||
bool isNonVisibleTag() const {
|
||||
return strcasecmp(tagName, "head") == 0 || strcasecmp(tagName, "style") == 0 ||
|
||||
strcasecmp(tagName, "script") == 0 || strcasecmp(tagName, "title") == 0;
|
||||
}
|
||||
|
||||
static bool isAttrWhitespace(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
|
||||
|
||||
static bool isAttrNameChar(uint8_t c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ||
|
||||
c == ':' || c == '.';
|
||||
}
|
||||
|
||||
void resetAnchorAttrScan() {
|
||||
attrState = ATTR_FIND_NAME;
|
||||
attrNameLen = 0;
|
||||
currentAttrIsId = false;
|
||||
}
|
||||
|
||||
void finishCapturedAnchorId() {
|
||||
capturedAnchorId[capturedAnchorIdLen] = '\0';
|
||||
capturingAnchorTag = false;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void beginAnchorIdScan() {
|
||||
capturingAnchorTag = true;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void endAnchorIdScan() {
|
||||
if (capturingAnchorTag) {
|
||||
capturedAnchorIdLen = 0;
|
||||
}
|
||||
capturingAnchorTag = false;
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
|
||||
void appendCapturedAnchorId(uint8_t c) {
|
||||
if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) {
|
||||
capturedAnchorId[capturedAnchorIdLen++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
void scanAnchorAttribute(uint8_t c) {
|
||||
switch (attrState) {
|
||||
case ATTR_FIND_NAME:
|
||||
if (isAttrNameChar(c)) {
|
||||
attrState = ATTR_READ_NAME;
|
||||
attrNameLen = 1;
|
||||
currentAttrIsId = c == 'i';
|
||||
}
|
||||
break;
|
||||
case ATTR_READ_NAME:
|
||||
if (isAttrNameChar(c)) {
|
||||
if (attrNameLen == 1) {
|
||||
currentAttrIsId = currentAttrIsId && c == 'd';
|
||||
} else {
|
||||
currentAttrIsId = false;
|
||||
}
|
||||
attrNameLen++;
|
||||
} else {
|
||||
currentAttrIsId = currentAttrIsId && attrNameLen == 2;
|
||||
if (isAttrWhitespace(c)) {
|
||||
attrState = ATTR_AFTER_NAME;
|
||||
} else if (c == '=') {
|
||||
attrState = ATTR_BEFORE_VALUE;
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ATTR_AFTER_NAME:
|
||||
if (isAttrWhitespace(c)) {
|
||||
break;
|
||||
}
|
||||
if (c == '=') {
|
||||
attrState = ATTR_BEFORE_VALUE;
|
||||
} else if (isAttrNameChar(c)) {
|
||||
attrState = ATTR_READ_NAME;
|
||||
attrNameLen = 1;
|
||||
currentAttrIsId = c == 'i';
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
break;
|
||||
case ATTR_BEFORE_VALUE:
|
||||
if (isAttrWhitespace(c)) {
|
||||
break;
|
||||
}
|
||||
if (currentAttrIsId && c == '"') {
|
||||
capturedAnchorIdLen = 0;
|
||||
attrState = ATTR_CAPTURE_D;
|
||||
} else if (currentAttrIsId && c == '\'') {
|
||||
capturedAnchorIdLen = 0;
|
||||
attrState = ATTR_CAPTURE_S;
|
||||
} else if (c == '"') {
|
||||
attrState = ATTR_CAPTURE_D;
|
||||
} else if (c == '\'') {
|
||||
attrState = ATTR_CAPTURE_S;
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
break;
|
||||
case ATTR_CAPTURE_D:
|
||||
if (c == '"') {
|
||||
if (currentAttrIsId) {
|
||||
finishCapturedAnchorId();
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
} else if (currentAttrIsId) {
|
||||
appendCapturedAnchorId(c);
|
||||
}
|
||||
break;
|
||||
case ATTR_CAPTURE_S:
|
||||
if (c == '\'') {
|
||||
if (currentAttrIsId) {
|
||||
finishCapturedAnchorId();
|
||||
} else {
|
||||
resetAnchorAttrScan();
|
||||
}
|
||||
} else if (currentAttrIsId) {
|
||||
appendCapturedAnchorId(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void onVisibleCodepoint() {
|
||||
totalVisChars++;
|
||||
if (revPFound && !revDone) {
|
||||
// Ancestry mode: count only while inside the fully-matched element and in the target text node.
|
||||
// Legacy mode: count only while still inside the matched paragraph and in the target text node.
|
||||
const bool inTargetNode = (stepCount > 0) ? (matchedDepth == stepCount && currentTextNode == targetTextNode)
|
||||
: (paragraphHtmlDepth >= 0 && currentTextNode == targetTextNode);
|
||||
if (inTargetNode) {
|
||||
revVisChars++;
|
||||
if (revVisChars >= revChar) {
|
||||
targetVisChars = totalVisChars;
|
||||
revDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onVisibleText(const char* text) {
|
||||
if (!text) return;
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text);
|
||||
while (*ptr != 0) {
|
||||
utf8NextCodepoint(&ptr);
|
||||
onVisibleCodepoint();
|
||||
}
|
||||
}
|
||||
|
||||
void flushEntityAsLiteral() {
|
||||
for (size_t i = 0; i < entityLen; i++) onVisibleCodepoint();
|
||||
}
|
||||
|
||||
void finishEntity() {
|
||||
entityBuffer[entityLen] = '\0';
|
||||
const char* resolved = lookupHtmlEntity(entityBuffer, entityLen);
|
||||
if (resolved)
|
||||
onVisibleText(resolved);
|
||||
else
|
||||
flushEntityAsLiteral();
|
||||
globalInEntity = false;
|
||||
entityLen = 0;
|
||||
}
|
||||
|
||||
void onLegacyP() {
|
||||
pCount++;
|
||||
if (!revPFound && revParagraph > 0 && pCount >= revParagraph) {
|
||||
revPFound = true;
|
||||
revVisChars = 0;
|
||||
paragraphHtmlDepth = htmlDepth;
|
||||
currentTextNode = 1;
|
||||
if (revChar <= 0 && targetTextNode <= 1) {
|
||||
targetVisChars = totalVisChars;
|
||||
revDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onOpenTag() {
|
||||
htmlDepth++;
|
||||
|
||||
if (nonVisibleDepth > 0 || isNonVisibleTag()) {
|
||||
nonVisibleDepth++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepCount == 0) {
|
||||
if (strcasecmp(tagName, "p") == 0) onLegacyP();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture a child <a id> inside the fully-matched element even after target char is found.
|
||||
if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) {
|
||||
beginAnchorIdScan();
|
||||
}
|
||||
|
||||
if (revDone) return;
|
||||
|
||||
if (strcasecmp(tagName, "p") == 0) pCount++;
|
||||
if (strcasecmp(tagName, "li") == 0) liCount++;
|
||||
|
||||
if (matchedDepth < stepCount) {
|
||||
const XPathStep& target = steps[matchedDepth];
|
||||
if (strcasecmp(tagName, target.tag) == 0) {
|
||||
// Count only direct children of the previously matched ancestor step.
|
||||
// For step 0 any depth is valid; subsequent steps must be exactly one level deeper.
|
||||
const bool atCorrectDepth = (matchedDepth == 0) || (htmlDepth == stepEnteredAtDepth[matchedDepth - 1] + 1);
|
||||
if (!atCorrectDepth) return;
|
||||
siblingCounters[matchedDepth]++;
|
||||
if (siblingCounters[matchedDepth] == target.siblingIndex) {
|
||||
insideStep[matchedDepth] = true;
|
||||
stepEnteredAtDepth[matchedDepth] = htmlDepth;
|
||||
matchedDepth++;
|
||||
if (matchedDepth == stepCount) {
|
||||
beginAnchorIdScan();
|
||||
paragraphAtMatch = pCount;
|
||||
liCountAtMatch = liCount;
|
||||
revPFound = true;
|
||||
capturedAnchorIdLen = 0;
|
||||
revVisChars = 0;
|
||||
currentTextNode = 1; // Reset text node counter for this element
|
||||
if (revChar <= 0 && targetTextNode <= 1) {
|
||||
targetVisChars = totalVisChars;
|
||||
revDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onCloseTag() {
|
||||
if (nonVisibleDepth > 0) {
|
||||
nonVisibleDepth--;
|
||||
if (htmlDepth > 0) htmlDepth--;
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: each direct child element closing advances the text node index.
|
||||
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) {
|
||||
currentTextNode++;
|
||||
if (currentTextNode == targetTextNode && revChar <= 0) {
|
||||
targetVisChars = totalVisChars;
|
||||
revDone = true;
|
||||
}
|
||||
}
|
||||
// Legacy mode: stop tracking when the matched paragraph itself closes.
|
||||
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth) {
|
||||
revPFound = false;
|
||||
paragraphHtmlDepth = -1;
|
||||
}
|
||||
|
||||
// Ancestry mode: advance text node when a direct child of the fully-matched element closes.
|
||||
if (stepCount > 0 && matchedDepth == stepCount && revPFound && !revDone) {
|
||||
const int elementDepth = stepEnteredAtDepth[stepCount - 1];
|
||||
if (htmlDepth == elementDepth + 1) {
|
||||
currentTextNode++;
|
||||
if (currentTextNode == targetTextNode && revChar <= 0) {
|
||||
targetVisChars = totalVisChars;
|
||||
revDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stepCount > 0 && matchedDepth > 0) {
|
||||
const int step = matchedDepth - 1;
|
||||
if (insideStep[step] && htmlDepth == stepEnteredAtDepth[step]) {
|
||||
insideStep[step] = false;
|
||||
matchedDepth--;
|
||||
// If the fully-matched element just closed without finding the target, abort.
|
||||
if (matchedDepth < stepCount && revPFound && !revDone) {
|
||||
revPFound = false;
|
||||
}
|
||||
for (int i = matchedDepth + 1; i < stepCount; i++) {
|
||||
siblingCounters[i] = 0;
|
||||
insideStep[i] = false;
|
||||
stepEnteredAtDepth[i] = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (htmlDepth > 0) htmlDepth--;
|
||||
}
|
||||
|
||||
void processByteInTag(uint8_t c) {
|
||||
switch (tagState) {
|
||||
case TAG_IDLE:
|
||||
if (c == '/') {
|
||||
tagIsClose = true;
|
||||
tagState = TAG_IN_NAME;
|
||||
} else if (c != '!' && c != '?') {
|
||||
tagIsClose = false;
|
||||
tagName[0] = static_cast<char>(c);
|
||||
tagNameLen = 1;
|
||||
tagState = TAG_IN_NAME;
|
||||
}
|
||||
break;
|
||||
case TAG_IN_NAME:
|
||||
if (c == '>' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/') {
|
||||
tagName[tagNameLen] = '\0';
|
||||
if (tagNameLen > 0) {
|
||||
if (tagIsClose)
|
||||
onCloseTag();
|
||||
else
|
||||
onOpenTag();
|
||||
// Self-closing open tag (<br/>). Don't double-fire for close tags (</br/>).
|
||||
if (c == '/' && !tagIsClose) onCloseTag();
|
||||
}
|
||||
tagNameLen = 0;
|
||||
tagState = (c == '>') ? TAG_IDLE : TAG_ATTRS;
|
||||
} else if (tagNameLen + 1 < static_cast<int>(sizeof(tagName))) {
|
||||
tagName[tagNameLen++] = static_cast<char>(c);
|
||||
}
|
||||
break;
|
||||
case TAG_ATTRS:
|
||||
// Track quoted attribute values so '/' inside them is not mistaken for self-closing.
|
||||
if (!inAttrQuote) {
|
||||
if (c == '"' || c == '\'') {
|
||||
inAttrQuote = true;
|
||||
attrQuoteChar = c;
|
||||
}
|
||||
} else if (c == attrQuoteChar) {
|
||||
inAttrQuote = false;
|
||||
attrQuoteChar = 0;
|
||||
}
|
||||
if (capturingAnchorTag) {
|
||||
scanAnchorAttribute(c);
|
||||
}
|
||||
// Only treat '/' as self-closing when outside a quoted attribute value.
|
||||
if (c == '/' && !inAttrQuote) {
|
||||
endAnchorIdScan();
|
||||
onCloseTag();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
explicit ParagraphStreamer(size_t targetByte) : fwdTarget(targetByte), revChar(0) {
|
||||
memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth));
|
||||
}
|
||||
|
||||
ParagraphStreamer(int paragraph, int charOff, int textNodeIdx = 1)
|
||||
: fwdTarget(SIZE_MAX), revChar(charOff), revParagraph(paragraph), targetTextNode(textNodeIdx) {
|
||||
memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth));
|
||||
}
|
||||
|
||||
ParagraphStreamer(const XPathStep* xpathSteps, int xpathStepCount, int charOff, int textNodeIdx = 1)
|
||||
: fwdTarget(SIZE_MAX),
|
||||
revChar(charOff),
|
||||
steps(xpathSteps),
|
||||
stepCount(xpathStepCount),
|
||||
targetTextNode(textNodeIdx) {
|
||||
memset(stepEnteredAtDepth, -1, sizeof(stepEnteredAtDepth));
|
||||
}
|
||||
|
||||
size_t write(uint8_t c) override {
|
||||
if (!fwdCaptured && bytesWritten >= fwdTarget) {
|
||||
fwdResult = pCount;
|
||||
fwdCaptured = true;
|
||||
}
|
||||
bytesWritten++;
|
||||
|
||||
if (globalInEntity) {
|
||||
if (entityLen + 1 < MAX_ENTITY_SIZE) {
|
||||
entityBuffer[entityLen++] = static_cast<char>(c);
|
||||
} else {
|
||||
flushEntityAsLiteral();
|
||||
globalInEntity = false;
|
||||
entityLen = 0;
|
||||
}
|
||||
if (globalInEntity) {
|
||||
if (c == ';') {
|
||||
finishEntity();
|
||||
} else if (c == '<' || c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
flushEntityAsLiteral();
|
||||
globalInEntity = false;
|
||||
entityLen = 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (c == '<') {
|
||||
globalInTag = true;
|
||||
tagState = TAG_IDLE;
|
||||
tagNameLen = 0;
|
||||
tagIsClose = false;
|
||||
capturingAnchorTag = false;
|
||||
resetAnchorAttrScan();
|
||||
inAttrQuote = false;
|
||||
attrQuoteChar = 0;
|
||||
} else if (c == '>') {
|
||||
if (tagState == TAG_ATTRS) {
|
||||
endAnchorIdScan();
|
||||
}
|
||||
globalInTag = false;
|
||||
inAttrQuote = false;
|
||||
if (tagState == TAG_IN_NAME && tagNameLen > 0) {
|
||||
tagName[tagNameLen] = '\0';
|
||||
if (tagIsClose)
|
||||
onCloseTag();
|
||||
else
|
||||
onOpenTag();
|
||||
tagNameLen = 0;
|
||||
}
|
||||
tagState = TAG_IDLE;
|
||||
} else if (globalInTag) {
|
||||
processByteInTag(c);
|
||||
} else if (nonVisibleDepth > 0) {
|
||||
// Ignore head/style/script/title text. KOReader XPaths are body-relative, and CSS text
|
||||
// should not contribute to intra-spine progress.
|
||||
} else {
|
||||
if (c == '&') {
|
||||
globalInEntity = true;
|
||||
entityBuffer[0] = '&';
|
||||
entityLen = 1;
|
||||
} else {
|
||||
const bool startsCodepoint = (c & 0xC0) != 0x80;
|
||||
if (startsCodepoint) onVisibleCodepoint();
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t write(const uint8_t* buffer, size_t size) override {
|
||||
for (size_t i = 0; i < size; i++) write(buffer[i]);
|
||||
return size;
|
||||
}
|
||||
|
||||
int paragraphCount() const { return fwdCaptured ? fwdResult : pCount; }
|
||||
int getParagraphAtMatch() const { return paragraphAtMatch; }
|
||||
int getListItemAtMatch() const { return liCountAtMatch; }
|
||||
const char* getCapturedAnchorId() const { return capturedAnchorIdLen > 0 ? capturedAnchorId : nullptr; }
|
||||
size_t totalBytes() const { return bytesWritten; }
|
||||
bool found() const { return revDone || revPFound; }
|
||||
size_t getTotalVisChars() const { return totalVisChars; }
|
||||
size_t getTargetVisChars() const { return targetVisChars; }
|
||||
float progress() const {
|
||||
return totalVisChars > 0 ? static_cast<float>(targetVisChars) / static_cast<float>(totalVisChars) : 0.0f;
|
||||
}
|
||||
};
|
||||
|
||||
bool streamSpine(const std::shared_ptr<Epub>& epub, int spineIndex, ParagraphStreamer& s) {
|
||||
const auto href = epub->getSpineItem(spineIndex).href;
|
||||
return !href.empty() && epub->readItemContentsToStream(href, s, 1024);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub>& epub,
|
||||
const CrossPointPosition& pos) {
|
||||
SavedProgressPosition result;
|
||||
float intra =
|
||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
||||
}
|
||||
// Fall back to progress-based XPath, then synthetic progress mapping.
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||
}
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||
}
|
||||
LOG_DBG("PM", "-> Progress: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages,
|
||||
result.percentage * 100, result.xpath.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
||||
GfxRenderer& renderer, int currentSpineIndex,
|
||||
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||
CrossPointPosition result{};
|
||||
const size_t bookSize = epub->getBookSize();
|
||||
if (bookSize == 0) return result;
|
||||
|
||||
const int spineCount = epub->getSpineItemsCount();
|
||||
const float clampedPercentage = std::max(0.0f, std::min(1.0f, koPos.percentage));
|
||||
const size_t targetBytes = static_cast<size_t>(static_cast<float>(bookSize) * clampedPercentage);
|
||||
|
||||
const int docFrag = parseIndex(koPos.xpath, "/body/DocFragment[");
|
||||
const int xpathP = parseIndex(koPos.xpath, "/p[", true);
|
||||
const int xpathChar = parseCharOffset(koPos.xpath);
|
||||
const int xpathTextNode = parseTextNodeIndex(koPos.xpath);
|
||||
const int xpathSpine = (docFrag >= 1) ? (docFrag - 1) : -1;
|
||||
|
||||
XPathStep xpathSteps[MAX_XPATH_DEPTH];
|
||||
const int xpathStepCount = parseXPathSteps(koPos.xpath, xpathSteps);
|
||||
// Use ancestry mode whenever the XPath has a structured path (always more accurate than global counting).
|
||||
const bool useAncestry = xpathStepCount > 0;
|
||||
|
||||
if (xpathSpine >= 0 && xpathSpine < spineCount) {
|
||||
result.spineIndex = xpathSpine;
|
||||
} else {
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
if (epub->getCumulativeSpineItemSize(i) >= targetBytes) {
|
||||
result.spineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||
const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
|
||||
|
||||
if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) {
|
||||
result.totalPages = totalPagesInCurrentSpine;
|
||||
} else if (currentSpineIndex >= 0 && currentSpineIndex < spineCount && totalPagesInCurrentSpine > 0) {
|
||||
const size_t pc = (currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
||||
const size_t cs = epub->getCumulativeSpineItemSize(currentSpineIndex) - pc;
|
||||
if (cs > 0)
|
||||
result.totalPages = std::max(
|
||||
1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs)));
|
||||
}
|
||||
|
||||
if (result.totalPages <= 0) {
|
||||
Section tempSection(epub, result.spineIndex, renderer);
|
||||
if (auto cachedCount = tempSection.getCachedPageCount()) {
|
||||
result.totalPages = *cachedCount;
|
||||
} else if (fallbackTotalPages > 0) {
|
||||
result.totalPages = fallbackTotalPages;
|
||||
} else {
|
||||
result.totalPages = 1; // Prevent division by zero and give a fallback
|
||||
}
|
||||
}
|
||||
|
||||
float intra = 0.0f;
|
||||
bool resolvedIntra = false;
|
||||
if (useAncestry) {
|
||||
ParagraphStreamer s(xpathSteps, xpathStepCount, xpathChar, xpathTextNode);
|
||||
if (streamSpine(epub, result.spineIndex, s) && s.found()) {
|
||||
intra = s.progress();
|
||||
resolvedIntra = true;
|
||||
const int pAtMatch = s.getParagraphAtMatch();
|
||||
if (pAtMatch > 0) {
|
||||
result.paragraphIndex = static_cast<uint16_t>(pAtMatch);
|
||||
result.hasParagraphIndex = true;
|
||||
}
|
||||
if (xpathStepCount > 0 && strcasecmp(xpathSteps[xpathStepCount - 1].tag, "li") == 0) {
|
||||
const int liAtMatch = s.getListItemAtMatch();
|
||||
if (liAtMatch > 0) {
|
||||
result.liIndex = static_cast<uint16_t>(liAtMatch);
|
||||
result.hasLiIndex = true;
|
||||
}
|
||||
}
|
||||
const char* anchorId = s.getCapturedAnchorId();
|
||||
if (anchorId) {
|
||||
strncpy(result.xpathAnchorId, anchorId, sizeof(result.xpathAnchorId) - 1);
|
||||
}
|
||||
LOG_DBG("PM", "XPath ancestry(%s[%d])/text()[%d]+%d -> %.1f%% (target=%zu total=%zu p~%d li~%d anchor=%s)",
|
||||
xpathSteps[xpathStepCount - 1].tag, xpathSteps[xpathStepCount - 1].siblingIndex, xpathTextNode, xpathChar,
|
||||
intra * 100, s.getTargetVisChars(), s.getTotalVisChars(), pAtMatch,
|
||||
result.hasLiIndex ? static_cast<int>(result.liIndex) : 0, anchorId ? anchorId : "none");
|
||||
}
|
||||
} else if (xpathP > 0) {
|
||||
ParagraphStreamer s(xpathP, xpathChar, xpathTextNode);
|
||||
if (streamSpine(epub, result.spineIndex, s) && s.found()) {
|
||||
intra = s.progress();
|
||||
resolvedIntra = true;
|
||||
LOG_DBG("PM", "XPath p[%d]/text()[%d]+%d -> %.1f%% (target=%zu total=%zu)", xpathP, xpathTextNode, xpathChar,
|
||||
intra * 100, s.getTargetVisChars(), s.getTotalVisChars());
|
||||
}
|
||||
}
|
||||
if (!resolvedIntra && xpathSpine >= 0 && xpathSpine < spineCount && isChapterStartXPath(koPos.xpath)) {
|
||||
intra = 0.0f;
|
||||
resolvedIntra = true;
|
||||
LOG_DBG("PM", "Chapter-start XPath %s -> spine=%d page start", koPos.xpath.c_str(), result.spineIndex);
|
||||
}
|
||||
if (!resolvedIntra) {
|
||||
const size_t bytesIn = (targetBytes > prevCum) ? (targetBytes - prevCum) : 0;
|
||||
intra = std::max(0.0f, std::min(1.0f, static_cast<float>(bytesIn) / static_cast<float>(spineSize)));
|
||||
}
|
||||
|
||||
result.pageNumber = std::max(
|
||||
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
|
||||
LOG_DBG("PM", "<- Progress: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(),
|
||||
result.spineIndex, result.pageNumber, result.totalPages);
|
||||
|
||||
// Refine page using section cache LUTs: li index, anchor, or paragraph index.
|
||||
if (result.hasLiIndex || result.xpathAnchorId[0] != '\0' || result.hasParagraphIndex) {
|
||||
Section tempSection(epub, result.spineIndex, renderer);
|
||||
bool refined = false;
|
||||
if (result.hasLiIndex) {
|
||||
const auto liPage = tempSection.getPageForListItemIndex(result.liIndex);
|
||||
if (liPage.has_value()) {
|
||||
LOG_DBG("PM", "Li index %u -> page %d (was %d)", result.liIndex, *liPage, result.pageNumber);
|
||||
result.pageNumber = *liPage;
|
||||
refined = true;
|
||||
} else {
|
||||
LOG_DBG("PM", "Li index %u not found in section LUT", result.liIndex);
|
||||
}
|
||||
}
|
||||
if (!refined && result.xpathAnchorId[0] != '\0') {
|
||||
const auto anchorPage = tempSection.getPageForAnchor(std::string(result.xpathAnchorId));
|
||||
if (anchorPage.has_value()) {
|
||||
LOG_DBG("PM", "Anchor '%s' -> page %d (was %d)", result.xpathAnchorId, *anchorPage, result.pageNumber);
|
||||
result.pageNumber = *anchorPage;
|
||||
refined = true;
|
||||
} else {
|
||||
LOG_DBG("PM", "Anchor '%s' not found in section cache", result.xpathAnchorId);
|
||||
}
|
||||
}
|
||||
if (!refined && result.hasParagraphIndex) {
|
||||
const auto paragraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex);
|
||||
const auto nextParagraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex + 1);
|
||||
if (paragraphPage.has_value()) {
|
||||
int refinedPage = std::max(result.pageNumber, static_cast<int>(*paragraphPage));
|
||||
if (nextParagraphPage.has_value()) {
|
||||
const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*paragraphPage);
|
||||
// Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too
|
||||
// coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph
|
||||
// occupies different pages than at build time).
|
||||
if (lutSpan > 1 && refinedPage >= static_cast<int>(*nextParagraphPage)) {
|
||||
refinedPage = static_cast<int>(*nextParagraphPage) - 1;
|
||||
}
|
||||
}
|
||||
char nextParaBuf[8];
|
||||
if (nextParagraphPage.has_value())
|
||||
snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage);
|
||||
else
|
||||
snprintf(nextParaBuf, sizeof(nextParaBuf), "none");
|
||||
LOG_DBG("PM", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d", result.paragraphIndex,
|
||||
*paragraphPage, nextParaBuf, result.pageNumber, refinedPage);
|
||||
result.pageNumber = refinedPage;
|
||||
} else {
|
||||
LOG_DBG("PM", "Paragraph %u not found in section LUT", result.paragraphIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ProgressMapper::generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intra) {
|
||||
const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
|
||||
if (intra <= 0.0f) return base;
|
||||
|
||||
size_t spineSize = 0;
|
||||
const auto href = epub->getSpineItem(spineIndex).href;
|
||||
if (href.empty() || !epub->getItemSize(href, &spineSize) || spineSize == 0) return base;
|
||||
|
||||
ParagraphStreamer s(static_cast<size_t>(spineSize * std::min(intra, 1.0f)));
|
||||
if (!streamSpine(epub, spineIndex, s)) return base;
|
||||
|
||||
const int p = s.paragraphCount();
|
||||
return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* CrossPoint position representation.
|
||||
*/
|
||||
struct CrossPointPosition {
|
||||
int spineIndex; // Current spine item (chapter) index
|
||||
int pageNumber; // Current page within the spine item
|
||||
int totalPages; // Total pages in the current spine item
|
||||
uint16_t paragraphIndex = 0; // 1-based synthetic paragraph index from XPath p[N]
|
||||
bool hasParagraphIndex = false; // True when paragraphIndex was resolved from XPath
|
||||
uint16_t liIndex = 0; // Running <li> count at the matched XPath element
|
||||
bool hasLiIndex = false; // True when target element is <li> and liIndex was resolved
|
||||
char xpathAnchorId[64] = {}; // First <a id> captured inside the matched XPath element
|
||||
};
|
||||
|
||||
#include "KOReaderPosition.h" // SavedProgressPosition
|
||||
|
||||
/**
|
||||
* Maps between CrossPoint and SavedProgress position formats, such as those used by KOReader.
|
||||
*
|
||||
* CrossPoint tracks position as (spineIndex, pageNumber).
|
||||
* SavedProgress uses XPath-like strings + percentage.
|
||||
*
|
||||
* Since CrossPoint discards HTML structure during parsing, we generate
|
||||
* synthetic XPath strings based on spine index, using percentage as the
|
||||
* primary sync mechanism.
|
||||
*/
|
||||
class ProgressMapper {
|
||||
public:
|
||||
/**
|
||||
* Convert CrossPoint position to SavedProgress format.
|
||||
*
|
||||
* @param epub The EPUB book
|
||||
* @param pos CrossPoint position
|
||||
* @return SavedProgress position
|
||||
*/
|
||||
static SavedProgressPosition toSavedProgress(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos);
|
||||
|
||||
/**
|
||||
* Convert SavedProgress position to CrossPoint format.
|
||||
*
|
||||
* Note: The returned pageNumber may be approximate since different
|
||||
* rendering settings produce different page counts.
|
||||
*
|
||||
* @param epub The EPUB book
|
||||
* @param savedPos SavedProgress position
|
||||
* @param renderer GfxRenderer for page count estimation
|
||||
* @param currentSpineIndex Index of the currently open spine item (for density estimation)
|
||||
* @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation)
|
||||
* @return CrossPoint position
|
||||
*/
|
||||
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& savedPos,
|
||||
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||
* a paragraph/text position from intra-spine progress.
|
||||
* Produces a full ancestry path such as
|
||||
* /body/DocFragment[3]/body/p[42]/text().17.
|
||||
*/
|
||||
static std::string generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
|
||||
};
|
||||
Reference in New Issue
Block a user