fix: Switch to xpath map for paragraph level syncing in KOSync (#1686)
Switch KOReader sync progress mapping from chapter matching to XPath-based mapping. - resolves KOReader positions using real XHTML ancestry paths - supports paragraph-based upload mapping with text offsets where needed - passes the current paragraph index into sync so uploads map back to KOReader more accurately No HTTP client changes are included. No reader-state or resume-flow changes are included. --------- Co-authored-by: jpirnay <jens@pirnay.com>
This commit is contained in:
co-authored by
jpirnay
parent
e8645ed92e
commit
302dea1eea
+99
-12
@@ -10,10 +10,15 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 19;
|
constexpr uint8_t SECTION_FILE_VERSION = 20;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t);
|
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t);
|
||||||
|
|
||||||
|
struct PageLutEntry {
|
||||||
|
uint32_t fileOffset;
|
||||||
|
uint16_t paragraphIndex;
|
||||||
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||||
@@ -44,7 +49,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
||||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t),
|
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) +
|
||||||
|
sizeof(uint32_t) + sizeof(uint32_t),
|
||||||
"Header size mismatch");
|
"Header size mismatch");
|
||||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||||
serialization::writePod(file, fontId);
|
serialization::writePod(file, fontId);
|
||||||
@@ -59,6 +65,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
|
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
|
||||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
|
||||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
|
||||||
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for paragraph LUT offset (patched later)
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
@@ -190,7 +197,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
}
|
}
|
||||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
|
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering);
|
||||||
std::vector<uint32_t> lut = {};
|
std::vector<PageLutEntry> lut = {};
|
||||||
|
|
||||||
// Derive the content base directory and image cache path prefix for the parser
|
// Derive the content base directory and image cache path prefix for the parser
|
||||||
size_t lastSlash = localPath.find_last_of('/');
|
size_t lastSlash = localPath.find_last_of('/');
|
||||||
@@ -210,7 +217,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled,
|
viewportHeight, hyphenationEnabled,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex) {
|
||||||
|
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex});
|
||||||
|
},
|
||||||
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
|
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
@@ -230,12 +239,12 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
const uint32_t lutOffset = file.position();
|
const uint32_t lutOffset = file.position();
|
||||||
bool hasFailedLutRecords = false;
|
bool hasFailedLutRecords = false;
|
||||||
// Write LUT
|
// Write LUT
|
||||||
for (const uint32_t& pos : lut) {
|
for (const auto& entry : lut) {
|
||||||
if (pos == 0) {
|
if (entry.fileOffset == 0) {
|
||||||
hasFailedLutRecords = true;
|
hasFailedLutRecords = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
serialization::writePod(file, pos);
|
serialization::writePod(file, entry.fileOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFailedLutRecords) {
|
if (hasFailedLutRecords) {
|
||||||
@@ -255,11 +264,18 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
serialization::writePod(file, page);
|
serialization::writePod(file, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch header with final pageCount, lutOffset, and anchorMapOffset
|
const uint32_t paragraphLutOffset = file.position();
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount));
|
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
||||||
|
for (const auto& entry : lut) {
|
||||||
|
serialization::writePod(file, entry.paragraphIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch header with final pageCount, lutOffset, anchorMapOffset, and paragraphLutOffset
|
||||||
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3 - sizeof(pageCount));
|
||||||
serialization::writePod(file, pageCount);
|
serialization::writePod(file, pageCount);
|
||||||
serialization::writePod(file, lutOffset);
|
serialization::writePod(file, lutOffset);
|
||||||
serialization::writePod(file, anchorMapOffset);
|
serialization::writePod(file, anchorMapOffset);
|
||||||
|
serialization::writePod(file, paragraphLutOffset);
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
if (cssParser) {
|
if (cssParser) {
|
||||||
@@ -273,7 +289,7 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3);
|
||||||
uint32_t lutOffset;
|
uint32_t lutOffset;
|
||||||
serialization::readPod(file, lutOffset);
|
serialization::readPod(file, lutOffset);
|
||||||
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
||||||
@@ -294,7 +310,7 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t fileSize = f.size();
|
const uint32_t fileSize = f.size();
|
||||||
f.seek(HEADER_SIZE - sizeof(uint32_t));
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
|
||||||
uint32_t anchorMapOffset;
|
uint32_t anchorMapOffset;
|
||||||
serialization::readPod(f, anchorMapOffset);
|
serialization::readPod(f, anchorMapOffset);
|
||||||
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
|
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
|
||||||
@@ -316,3 +332,74 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
|
|||||||
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex) const {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t fileSize = f.size();
|
||||||
|
f.seek(HEADER_SIZE - sizeof(uint32_t));
|
||||||
|
uint32_t paragraphLutOffset;
|
||||||
|
serialization::readPod(f, paragraphLutOffset);
|
||||||
|
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.seek(paragraphLutOffset);
|
||||||
|
uint16_t count;
|
||||||
|
serialization::readPod(f, count);
|
||||||
|
if (count == 0) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t);
|
||||||
|
if (lutEnd > fileSize) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t resultPage = count - 1;
|
||||||
|
for (uint16_t i = 0; i < count; i++) {
|
||||||
|
uint16_t pagePIdx;
|
||||||
|
serialization::readPod(f, pagePIdx);
|
||||||
|
if (pagePIdx >= pIndex) {
|
||||||
|
resultPage = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) const {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t fileSize = f.size();
|
||||||
|
f.seek(HEADER_SIZE - sizeof(uint32_t));
|
||||||
|
uint32_t paragraphLutOffset;
|
||||||
|
serialization::readPod(f, paragraphLutOffset);
|
||||||
|
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.seek(paragraphLutOffset);
|
||||||
|
uint16_t count;
|
||||||
|
serialization::readPod(f, count);
|
||||||
|
if (count == 0 || page >= count) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t);
|
||||||
|
if (entryEnd > fileSize) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t));
|
||||||
|
uint16_t pIdx;
|
||||||
|
serialization::readPod(f, pIdx);
|
||||||
|
return pIdx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,4 +42,10 @@ class Section {
|
|||||||
|
|
||||||
// Look up the page number for an anchor id from the section cache file.
|
// Look up the page number for an anchor id from the section cache file.
|
||||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||||
|
|
||||||
|
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
||||||
|
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
||||||
|
|
||||||
|
// Look up the synthetic paragraph index for the given rendered page.
|
||||||
|
std::optional<uint16_t> getParagraphIndexForPage(uint16_t page) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "p") == 0) {
|
||||||
|
self->xpathParagraphIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract class, style, and id attributes
|
// Extract class, style, and id attributes
|
||||||
std::string classAttr;
|
std::string classAttr;
|
||||||
std::string styleAttr;
|
std::string styleAttr;
|
||||||
@@ -428,7 +432,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
// Create page for image - only break if image won't fit remaining space
|
// Create page for image - only break if image won't fit remaining space
|
||||||
if (self->currentPage && !self->currentPage->elements.empty() &&
|
if (self->currentPage && !self->currentPage->elements.empty() &&
|
||||||
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
|
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
|
||||||
self->completePageFn(std::move(self->currentPage));
|
self->completePageFn(std::move(self->currentPage), self->xpathParagraphIndex);
|
||||||
self->completedPageCount++;
|
self->completedPageCount++;
|
||||||
self->currentPage.reset(new Page());
|
self->currentPage.reset(new Page());
|
||||||
if (!self->currentPage) {
|
if (!self->currentPage) {
|
||||||
@@ -1066,7 +1070,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|||||||
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
|
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
|
||||||
pendingAnchorId.clear();
|
pendingAnchorId.clear();
|
||||||
}
|
}
|
||||||
completePageFn(std::move(currentPage));
|
completePageFn(std::move(currentPage), xpathParagraphIndex);
|
||||||
completedPageCount++;
|
completedPageCount++;
|
||||||
currentPage.reset();
|
currentPage.reset();
|
||||||
currentTextBlock.reset();
|
currentTextBlock.reset();
|
||||||
@@ -1084,7 +1088,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentPageNextY + lineHeight > viewportHeight) {
|
if (currentPageNextY + lineHeight > viewportHeight) {
|
||||||
completePageFn(std::move(currentPage));
|
completePageFn(std::move(currentPage), xpathParagraphIndex);
|
||||||
completedPageCount++;
|
completedPageCount++;
|
||||||
currentPage.reset(new Page());
|
currentPage.reset(new Page());
|
||||||
currentPageNextY = 0;
|
currentPageNextY = 0;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ChapterHtmlSlimParser {
|
|||||||
std::shared_ptr<Epub> epub;
|
std::shared_ptr<Epub> epub;
|
||||||
const std::string& filepath;
|
const std::string& filepath;
|
||||||
GfxRenderer& renderer;
|
GfxRenderer& renderer;
|
||||||
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
std::function<void(std::unique_ptr<Page>, uint16_t)> completePageFn;
|
||||||
std::function<void()> popupFn; // Popup callback
|
std::function<void()> popupFn; // Popup callback
|
||||||
int depth = 0;
|
int depth = 0;
|
||||||
int skipUntilDepth = INT_MAX;
|
int skipUntilDepth = INT_MAX;
|
||||||
@@ -74,6 +74,7 @@ class ChapterHtmlSlimParser {
|
|||||||
int completedPageCount = 0;
|
int completedPageCount = 0;
|
||||||
std::vector<std::pair<std::string, uint16_t>> anchorData;
|
std::vector<std::pair<std::string, uint16_t>> anchorData;
|
||||||
std::string pendingAnchorId; // deferred until after previous text block is flushed
|
std::string pendingAnchorId; // deferred until after previous text block is flushed
|
||||||
|
uint16_t xpathParagraphIndex = 0;
|
||||||
|
|
||||||
// Footnote link tracking
|
// Footnote link tracking
|
||||||
bool insideFootnoteLink = false;
|
bool insideFootnoteLink = false;
|
||||||
@@ -99,7 +100,7 @@ class ChapterHtmlSlimParser {
|
|||||||
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
const std::function<void(std::unique_ptr<Page>, uint16_t)>& completePageFn,
|
||||||
const bool embeddedStyle, const std::string& contentBase,
|
const bool embeddedStyle, const std::string& contentBase,
|
||||||
const std::string& imageBasePath, const uint8_t imageRendering = 0,
|
const std::string& imageBasePath, const uint8_t imageRendering = 0,
|
||||||
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
|
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
#include "ChapterXPathResolver.h"
|
||||||
|
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <Print.h>
|
||||||
|
#include <Utf8.h>
|
||||||
|
#include <XmlParserUtils.h>
|
||||||
|
#include <expat.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::string stripPrefix(const XML_Char* name) {
|
||||||
|
if (!name) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* local = std::strrchr(name, ':');
|
||||||
|
return local ? std::string(local + 1) : std::string(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NameCounter {
|
||||||
|
std::string name;
|
||||||
|
int count;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ParentState {
|
||||||
|
std::vector<NameCounter> children;
|
||||||
|
|
||||||
|
int nextIndex(const std::string& name) {
|
||||||
|
for (auto& child : children) {
|
||||||
|
if (child.name == name) {
|
||||||
|
child.count++;
|
||||||
|
return child.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push_back({name, 1});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PathSegment {
|
||||||
|
std::string name;
|
||||||
|
int index;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string buildParagraphXPath(const int spineIndex, const std::vector<PathSegment>& path, const int 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 (charOffset > 0) {
|
||||||
|
xpath += "/text()." + std::to_string(charOffset);
|
||||||
|
}
|
||||||
|
return xpath;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t countUtf8Codepoints(const XML_Char* data, const int len) {
|
||||||
|
if (!data || len <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t count = 0;
|
||||||
|
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(data);
|
||||||
|
const unsigned char* end = ptr + len;
|
||||||
|
while (ptr < end) {
|
||||||
|
utf8NextCodepoint(&ptr);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParagraphTextCounter final : public Print {
|
||||||
|
public:
|
||||||
|
ParagraphTextCounter() {
|
||||||
|
parser = XML_ParserCreate(nullptr);
|
||||||
|
if (!parser) {
|
||||||
|
LOG_ERR("KOX", "Failed to create XML parser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
XML_SetUserData(parser, this);
|
||||||
|
XML_SetElementHandler(parser, &ParagraphTextCounter::startElement, &ParagraphTextCounter::endElement);
|
||||||
|
XML_SetCharacterDataHandler(parser, &ParagraphTextCounter::characterData);
|
||||||
|
}
|
||||||
|
|
||||||
|
~ParagraphTextCounter() override { destroyXmlParser(parser); }
|
||||||
|
|
||||||
|
bool ok() const { return parser != nullptr && parseOk; }
|
||||||
|
|
||||||
|
bool finish() {
|
||||||
|
if (!parser || !parseOk || stopped) {
|
||||||
|
return parseOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (XML_Parse(parser, "", 0, XML_TRUE) == XML_STATUS_ERROR) {
|
||||||
|
LOG_ERR("KOX", "Final XML parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
|
||||||
|
parseOk = false;
|
||||||
|
}
|
||||||
|
return parseOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t write(uint8_t c) override { return write(&c, 1); }
|
||||||
|
|
||||||
|
size_t write(const uint8_t* buffer, size_t size) override {
|
||||||
|
if (!parser || !parseOk || stopped) {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (XML_Parse(parser, reinterpret_cast<const char*>(buffer), static_cast<int>(size), XML_FALSE) != XML_STATUS_OK) {
|
||||||
|
const enum XML_Error error = XML_GetErrorCode(parser);
|
||||||
|
if (error != XML_ERROR_ABORTED) {
|
||||||
|
LOG_ERR("KOX", "XML parse error: %s", XML_ErrorString(error));
|
||||||
|
parseOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t totalVisibleChars() const { return visibleChars; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char**) {
|
||||||
|
auto* self = static_cast<ParagraphTextCounter*>(userData);
|
||||||
|
self->onStartElement(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void XMLCALL endElement(void* userData, const XML_Char* name) {
|
||||||
|
auto* self = static_cast<ParagraphTextCounter*>(userData);
|
||||||
|
self->onEndElement(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void XMLCALL characterData(void* userData, const XML_Char* data, const int len) {
|
||||||
|
auto* self = static_cast<ParagraphTextCounter*>(userData);
|
||||||
|
self->onCharacterData(data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onStartElement(const XML_Char* rawName) {
|
||||||
|
const std::string name = stripPrefix(rawName);
|
||||||
|
|
||||||
|
if (!insideBody) {
|
||||||
|
if (name == "body") {
|
||||||
|
insideBody = true;
|
||||||
|
bodyDepth = depth;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name == "p") {
|
||||||
|
paragraphDepth++;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEndElement(const XML_Char* rawName) {
|
||||||
|
const std::string name = stripPrefix(rawName);
|
||||||
|
|
||||||
|
depth--;
|
||||||
|
if (!insideBody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth == bodyDepth && name == "body") {
|
||||||
|
insideBody = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name == "p" && paragraphDepth > 0) {
|
||||||
|
paragraphDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onCharacterData(const XML_Char* data, const int len) {
|
||||||
|
if (!insideBody || paragraphDepth <= 0 || len <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleChars += countUtf8Codepoints(data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
XML_Parser parser = nullptr;
|
||||||
|
bool parseOk = true;
|
||||||
|
bool insideBody = false;
|
||||||
|
bool stopped = false;
|
||||||
|
int depth = 0;
|
||||||
|
int bodyDepth = -1;
|
||||||
|
int paragraphDepth = 0;
|
||||||
|
size_t visibleChars = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class XPathParagraphResolver final : public Print {
|
||||||
|
public:
|
||||||
|
explicit XPathParagraphResolver(const int targetParagraph) : targetParagraph(targetParagraph) {
|
||||||
|
parser = XML_ParserCreate(nullptr);
|
||||||
|
if (!parser) {
|
||||||
|
LOG_ERR("KOX", "Failed to create XML parser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
XML_SetUserData(parser, this);
|
||||||
|
XML_SetElementHandler(parser, &XPathParagraphResolver::startElement, &XPathParagraphResolver::endElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
~XPathParagraphResolver() override { destroyXmlParser(parser); }
|
||||||
|
|
||||||
|
bool ok() const { return parser != nullptr && parseOk; }
|
||||||
|
|
||||||
|
bool finish() {
|
||||||
|
if (!parser || !parseOk || stopped) {
|
||||||
|
return parseOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (XML_Parse(parser, "", 0, XML_TRUE) == XML_STATUS_ERROR) {
|
||||||
|
LOG_ERR("KOX", "Final XML parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
|
||||||
|
parseOk = false;
|
||||||
|
}
|
||||||
|
return parseOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMatch() const { return !xpath.empty(); }
|
||||||
|
const std::string& getXPath() const { return xpath; }
|
||||||
|
|
||||||
|
size_t write(uint8_t c) override { return write(&c, 1); }
|
||||||
|
|
||||||
|
size_t write(const uint8_t* buffer, size_t size) override {
|
||||||
|
if (!parser || !parseOk || stopped) {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (XML_Parse(parser, reinterpret_cast<const char*>(buffer), static_cast<int>(size), XML_FALSE) != XML_STATUS_OK) {
|
||||||
|
const enum XML_Error error = XML_GetErrorCode(parser);
|
||||||
|
if (error != XML_ERROR_ABORTED) {
|
||||||
|
LOG_ERR("KOX", "XML parse error: %s", XML_ErrorString(error));
|
||||||
|
parseOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
int spineIndex = 0;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char**) {
|
||||||
|
auto* self = static_cast<XPathParagraphResolver*>(userData);
|
||||||
|
self->onStartElement(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void XMLCALL endElement(void* userData, const XML_Char* name) {
|
||||||
|
auto* self = static_cast<XPathParagraphResolver*>(userData);
|
||||||
|
self->onEndElement(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onStartElement(const XML_Char* rawName) {
|
||||||
|
const std::string name = stripPrefix(rawName);
|
||||||
|
|
||||||
|
if (!insideBody) {
|
||||||
|
if (name == "body") {
|
||||||
|
insideBody = true;
|
||||||
|
bodyDepth = depth;
|
||||||
|
parentStates.emplace_back();
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int siblingIndex = parentStates.back().nextIndex(name);
|
||||||
|
path.push_back({name, siblingIndex});
|
||||||
|
parentStates.emplace_back();
|
||||||
|
|
||||||
|
if (name == "p") {
|
||||||
|
paragraphCount++;
|
||||||
|
if (paragraphCount == targetParagraph) {
|
||||||
|
xpath = buildParagraphXPath(spineIndex, path, 0);
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (name == "p") {
|
||||||
|
paragraphDepth++;
|
||||||
|
paragraphVisibleChars = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (name == "p" && paragraphDepth > 0) {
|
||||||
|
paragraphDepth--;
|
||||||
|
paragraphVisibleChars = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || len <= 0 || stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t codepointCount = countUtf8Codepoints(data, len);
|
||||||
|
const size_t nextVisibleChars = visibleChars + codepointCount;
|
||||||
|
if (targetVisibleChar <= nextVisibleChars) {
|
||||||
|
const size_t delta = targetVisibleChar - visibleChars;
|
||||||
|
const int charOffset = static_cast<int>(paragraphVisibleChars + delta);
|
||||||
|
xpath = buildParagraphXPath(spineIndex, path, std::max(1, charOffset));
|
||||||
|
stopped = true;
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleChars = nextVisibleChars;
|
||||||
|
paragraphVisibleChars += codepointCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
XML_Parser parser = nullptr;
|
||||||
|
const size_t targetVisibleChar;
|
||||||
|
bool parseOk = true;
|
||||||
|
bool insideBody = false;
|
||||||
|
bool stopped = false;
|
||||||
|
int depth = 0;
|
||||||
|
int bodyDepth = -1;
|
||||||
|
int paragraphDepth = 0;
|
||||||
|
size_t visibleChars = 0;
|
||||||
|
size_t paragraphVisibleChars = 0;
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#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);
|
||||||
|
};
|
||||||
@@ -2,113 +2,294 @@
|
|||||||
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "ChapterXPathResolver.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()");
|
||||||
|
if (textPos == std::string::npos) return 0;
|
||||||
|
const size_t dotPos = xpath.find('.', textPos);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParagraphStreamer final : public Print {
|
||||||
|
size_t bytesWritten = 0;
|
||||||
|
bool globalInTag = false;
|
||||||
|
bool globalInEntity = false;
|
||||||
|
enum { IDLE, SAW_LT, SAW_LT_P } pState = IDLE;
|
||||||
|
static constexpr size_t MAX_ENTITY_SIZE = 16;
|
||||||
|
char entityBuffer[MAX_ENTITY_SIZE] = {};
|
||||||
|
size_t entityLen = 0;
|
||||||
|
|
||||||
|
// Forward mode: count paragraphs at a byte offset
|
||||||
|
size_t fwdTarget;
|
||||||
|
int fwdResult = 0;
|
||||||
|
bool fwdCaptured = false;
|
||||||
|
|
||||||
|
// Reverse mode: find position of Nth paragraph + char offset
|
||||||
|
int revParagraph;
|
||||||
|
int revChar;
|
||||||
|
int pCount = 0;
|
||||||
|
bool revPFound = false;
|
||||||
|
bool revDone = false;
|
||||||
|
int revVisChars = 0; // Visible chars counted WITHIN target paragraph
|
||||||
|
size_t totalVisChars = 0; // Total visible chars in entire file
|
||||||
|
size_t targetVisChars = 0; // Visible chars from start of file to target position
|
||||||
|
|
||||||
|
void onP() {
|
||||||
|
pCount++;
|
||||||
|
if (!revPFound && revParagraph > 0 && pCount >= revParagraph) {
|
||||||
|
revPFound = true;
|
||||||
|
revVisChars = 0;
|
||||||
|
if (revChar <= 0) {
|
||||||
|
targetVisChars = totalVisChars;
|
||||||
|
revDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onVisibleCodepoint() {
|
||||||
|
totalVisChars++;
|
||||||
|
if (revPFound && !revDone) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ParagraphStreamer(size_t targetByte) : fwdTarget(targetByte), revParagraph(0), revChar(0) {}
|
||||||
|
ParagraphStreamer(int paragraph, int charOff) : fwdTarget(SIZE_MAX), revParagraph(paragraph), revChar(charOff) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (c == '<') {
|
||||||
|
globalInTag = true;
|
||||||
|
} else if (c == '>') {
|
||||||
|
globalInTag = false;
|
||||||
|
} else if (!globalInTag) {
|
||||||
|
if (c == '&') {
|
||||||
|
globalInEntity = true;
|
||||||
|
entityBuffer[0] = '&';
|
||||||
|
entityLen = 1;
|
||||||
|
} else {
|
||||||
|
const bool startsCodepoint = (c & 0xC0) != 0x80;
|
||||||
|
if (startsCodepoint) {
|
||||||
|
onVisibleCodepoint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph detection
|
||||||
|
switch (pState) {
|
||||||
|
case IDLE:
|
||||||
|
if (c == '<') pState = SAW_LT;
|
||||||
|
break;
|
||||||
|
case SAW_LT:
|
||||||
|
pState = (c == 'p' || c == 'P') ? SAW_LT_P : ((c == '<') ? SAW_LT : IDLE);
|
||||||
|
break;
|
||||||
|
case SAW_LT_P:
|
||||||
|
if (c == '>' || c == '/' || c == ' ' || c == '\t' || c == '\n' || c == '\r') onP();
|
||||||
|
pState = (c == '<') ? SAW_LT : IDLE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
int paragraphCount() const { return fwdCaptured ? fwdResult : pCount; }
|
||||||
|
size_t totalBytes() const { return bytesWritten; }
|
||||||
|
bool found() const { return revDone || revPFound; }
|
||||||
|
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
|
||||||
|
|
||||||
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
||||||
KOReaderPosition result;
|
KOReaderPosition result;
|
||||||
|
float intra = (pos.totalPages > 0) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages) : 0.0f;
|
||||||
// Calculate page progress within current spine item
|
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||||
float intraSpineProgress = 0.0f;
|
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||||
if (pos.totalPages > 0) {
|
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
||||||
intraSpineProgress = static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages);
|
} else {
|
||||||
|
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||||
}
|
}
|
||||||
|
if (result.xpath.empty()) {
|
||||||
// Calculate overall book progress (0.0-1.0)
|
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||||
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
|
}
|
||||||
|
LOG_DBG("PM", "-> KO: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages,
|
||||||
// Generate XPath with estimated paragraph position based on page
|
result.percentage * 100, result.xpath.c_str());
|
||||||
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
|
|
||||||
|
|
||||||
// Get chapter info for logging
|
|
||||||
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
|
|
||||||
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
|
|
||||||
|
|
||||||
LOG_DBG("ProgressMapper", "CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s", chapterName.c_str(),
|
|
||||||
pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
||||||
int currentSpineIndex, int totalPagesInCurrentSpine) {
|
int currentSpineIndex, int totalPagesInCurrentSpine) {
|
||||||
CrossPointPosition result;
|
CrossPointPosition result{};
|
||||||
result.spineIndex = 0;
|
|
||||||
result.pageNumber = 0;
|
|
||||||
result.totalPages = 0;
|
|
||||||
|
|
||||||
const size_t bookSize = epub->getBookSize();
|
const size_t bookSize = epub->getBookSize();
|
||||||
if (bookSize == 0) {
|
if (bookSize == 0) return result;
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use percentage-based lookup for both spine and page positioning
|
|
||||||
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure
|
|
||||||
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
|
|
||||||
|
|
||||||
// Find the spine item that contains this byte position
|
|
||||||
const int spineCount = epub->getSpineItemsCount();
|
const int spineCount = epub->getSpineItemsCount();
|
||||||
bool spineFound = false;
|
const float clampedPercentage = std::max(0.0f, std::min(1.0f, koPos.percentage));
|
||||||
for (int i = 0; i < spineCount; i++) {
|
const size_t targetBytes = static_cast<size_t>(static_cast<float>(bookSize) * clampedPercentage);
|
||||||
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
|
||||||
if (cumulativeSize >= targetBytes) {
|
const int docFrag = parseIndex(koPos.xpath, "/body/DocFragment[");
|
||||||
result.spineIndex = i;
|
const int xpathP = parseIndex(koPos.xpath, "/p[", true);
|
||||||
spineFound = true;
|
const int xpathChar = parseCharOffset(koPos.xpath);
|
||||||
break;
|
const int xpathSpine = (docFrag >= 1) ? (docFrag - 1) : -1;
|
||||||
}
|
if (xpathP > 0) {
|
||||||
|
result.paragraphIndex = static_cast<uint16_t>(xpathP);
|
||||||
|
result.hasParagraphIndex = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
|
if (xpathSpine >= 0 && xpathSpine < spineCount) {
|
||||||
// default to the last spine item so we map to the end of the book instead of the beginning.
|
result.spineIndex = xpathSpine;
|
||||||
if (!spineFound && spineCount > 0) {
|
} else {
|
||||||
result.spineIndex = spineCount - 1;
|
for (int i = 0; i < spineCount; i++) {
|
||||||
}
|
if (epub->getCumulativeSpineItemSize(i) >= targetBytes) {
|
||||||
|
result.spineIndex = i;
|
||||||
// Estimate page number within the spine item using percentage
|
break;
|
||||||
if (result.spineIndex < epub->getSpineItemsCount()) {
|
|
||||||
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
|
||||||
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
|
||||||
const size_t spineSize = currentCumSize - prevCumSize;
|
|
||||||
|
|
||||||
int estimatedTotalPages = 0;
|
|
||||||
|
|
||||||
// If we are in the same spine, use the known total pages
|
|
||||||
if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) {
|
|
||||||
estimatedTotalPages = totalPagesInCurrentSpine;
|
|
||||||
}
|
|
||||||
// Otherwise try to estimate based on density from current spine
|
|
||||||
else if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount() && totalPagesInCurrentSpine > 0) {
|
|
||||||
const size_t prevCurrCumSize =
|
|
||||||
(currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
|
||||||
const size_t currCumSize = epub->getCumulativeSpineItemSize(currentSpineIndex);
|
|
||||||
const size_t currSpineSize = currCumSize - prevCurrCumSize;
|
|
||||||
|
|
||||||
if (currSpineSize > 0) {
|
|
||||||
float ratio = static_cast<float>(spineSize) / static_cast<float>(currSpineSize);
|
|
||||||
estimatedTotalPages = static_cast<int>(totalPagesInCurrentSpine * ratio);
|
|
||||||
if (estimatedTotalPages < 1) estimatedTotalPages = 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (result.spineIndex >= spineCount) return result;
|
||||||
|
|
||||||
result.totalPages = estimatedTotalPages;
|
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||||
|
const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
|
||||||
|
|
||||||
if (spineSize > 0 && estimatedTotalPages > 0) {
|
if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) {
|
||||||
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
result.totalPages = totalPagesInCurrentSpine;
|
||||||
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
} else if (currentSpineIndex >= 0 && currentSpineIndex < spineCount && totalPagesInCurrentSpine > 0) {
|
||||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
const size_t pc = (currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
||||||
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
|
const size_t cs = epub->getCumulativeSpineItemSize(currentSpineIndex) - pc;
|
||||||
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
|
if (cs > 0)
|
||||||
|
result.totalPages = std::max(
|
||||||
|
1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs)));
|
||||||
|
}
|
||||||
|
if (spineSize == 0 || result.totalPages == 0) return result;
|
||||||
|
|
||||||
|
float intra = 0.0f;
|
||||||
|
if (xpathP > 0) {
|
||||||
|
ParagraphStreamer s(xpathP, xpathChar);
|
||||||
|
if (streamSpine(epub, result.spineIndex, s) && s.found()) {
|
||||||
|
intra = s.progress();
|
||||||
|
LOG_DBG("PM", "XPath p[%d]+%d -> %.1f%%", xpathP, xpathChar, intra * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (intra <= 0.0f) {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
|
result.pageNumber = std::max(0, std::min(static_cast<int>(intra * result.totalPages), result.totalPages - 1));
|
||||||
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
LOG_DBG("PM", "<- KO: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(),
|
||||||
|
result.spineIndex, result.pageNumber, result.totalPages);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
|
std::string ProgressMapper::generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intra) {
|
||||||
// Use 0-based DocFragment indices for KOReader
|
const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
|
||||||
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
|
if (intra <= 0.0f) return base;
|
||||||
// Avoid specifying paragraph numbers as they may not exist in the target document
|
|
||||||
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
* CrossPoint position representation.
|
* CrossPoint position representation.
|
||||||
*/
|
*/
|
||||||
struct CrossPointPosition {
|
struct CrossPointPosition {
|
||||||
int spineIndex; // Current spine item (chapter) index
|
int spineIndex; // Current spine item (chapter) index
|
||||||
int pageNumber; // Current page within the spine item
|
int pageNumber; // Current page within the spine item
|
||||||
int totalPages; // Total pages in the current 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
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,9 +61,10 @@ class ProgressMapper {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* Generate XPath for KOReader compatibility.
|
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||||
* Format: /body/DocFragment[spineIndex+1]/body
|
* a paragraph/text position from intra-spine progress.
|
||||||
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
|
* Produces a full ancestry path such as
|
||||||
|
* /body/DocFragment[3]/body/p[42]/text().17.
|
||||||
*/
|
*/
|
||||||
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
|
static std::string generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <esp_system.h>
|
#include <esp_system.h>
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "EpubReaderChapterSelectionActivity.h"
|
#include "EpubReaderChapterSelectionActivity.h"
|
||||||
@@ -63,6 +65,13 @@ void EpubReaderActivity::onEnter() {
|
|||||||
if (dataSize == 4 || dataSize == 6) {
|
if (dataSize == 4 || dataSize == 6) {
|
||||||
currentSpineIndex = data[0] + (data[1] << 8);
|
currentSpineIndex = data[0] + (data[1] << 8);
|
||||||
nextPageNumber = data[2] + (data[3] << 8);
|
nextPageNumber = data[2] + (data[3] << 8);
|
||||||
|
if (nextPageNumber == UINT16_MAX) {
|
||||||
|
// UINT16_MAX is an in-memory navigation sentinel for "open previous
|
||||||
|
// chapter on its last page". It should never be treated as persisted
|
||||||
|
// resume state after sleep or reopen.
|
||||||
|
LOG_DBG("ERS", "Ignoring stale last-page sentinel from progress cache");
|
||||||
|
nextPageNumber = 0;
|
||||||
|
}
|
||||||
cachedSpineIndex = currentSpineIndex;
|
cachedSpineIndex = currentSpineIndex;
|
||||||
LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber);
|
LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber);
|
||||||
}
|
}
|
||||||
@@ -186,7 +195,8 @@ void EpubReaderActivity::loop() {
|
|||||||
onGoHome();
|
onGoHome();
|
||||||
} else {
|
} else {
|
||||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||||
nextPageNumber = UINT16_MAX;
|
nextPageNumber = 0;
|
||||||
|
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -390,11 +400,19 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
||||||
if (KOREADER_STORE.hasCredentials()) {
|
if (KOREADER_STORE.hasCredentials()) {
|
||||||
const int currentPage = section ? section->currentPage : 0;
|
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||||
const int totalPages = section ? section->pageCount : 0;
|
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||||
|
std::optional<uint16_t> paragraphIndex;
|
||||||
|
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||||
|
const uint16_t paragraphPage =
|
||||||
|
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
|
||||||
|
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
|
||||||
|
paragraphIndex = *pIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
startActivityForResult(
|
startActivityForResult(
|
||||||
std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex,
|
std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex,
|
||||||
currentPage, totalPages),
|
currentPage, totalPages, paragraphIndex),
|
||||||
[this](const ActivityResult& result) {
|
[this](const ActivityResult& result) {
|
||||||
if (!result.isCancelled) {
|
if (!result.isCancelled) {
|
||||||
const auto& sync = std::get<SyncResult>(result.data);
|
const auto& sync = std::get<SyncResult>(result.data);
|
||||||
@@ -402,6 +420,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
currentSpineIndex = sync.spineIndex;
|
currentSpineIndex = sync.spineIndex;
|
||||||
nextPageNumber = sync.page;
|
nextPageNumber = sync.page;
|
||||||
|
cachedChapterTotalPageCount = 0; // Prevent rescaling sync page
|
||||||
|
pendingPageJump.reset();
|
||||||
|
saveProgress(currentSpineIndex, nextPageNumber, 0);
|
||||||
section.reset();
|
section.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,7 +505,8 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
|||||||
// We don't want to delete the section mid-render, so grab the semaphore
|
// We don't want to delete the section mid-render, so grab the semaphore
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
nextPageNumber = UINT16_MAX;
|
nextPageNumber = 0;
|
||||||
|
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
||||||
currentSpineIndex--;
|
currentSpineIndex--;
|
||||||
section.reset();
|
section.reset();
|
||||||
}
|
}
|
||||||
@@ -566,10 +588,21 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
LOG_DBG("ERS", "Cache found, skipping build...");
|
LOG_DBG("ERS", "Cache found, skipping build...");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPageNumber == UINT16_MAX) {
|
if (pendingPageJump.has_value()) {
|
||||||
section->currentPage = section->pageCount - 1;
|
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
|
||||||
|
section->currentPage = section->pageCount - 1;
|
||||||
|
} else {
|
||||||
|
section->currentPage = *pendingPageJump;
|
||||||
|
}
|
||||||
|
pendingPageJump.reset();
|
||||||
} else {
|
} else {
|
||||||
section->currentPage = nextPageNumber;
|
section->currentPage = nextPageNumber;
|
||||||
|
if (section->currentPage < 0) {
|
||||||
|
section->currentPage = 0;
|
||||||
|
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
|
||||||
|
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
|
||||||
|
section->currentPage = section->pageCount - 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingAnchor.empty()) {
|
if (!pendingAnchor.empty()) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <Epub/FootnoteEntry.h>
|
#include <Epub/FootnoteEntry.h>
|
||||||
#include <Epub/Section.h>
|
#include <Epub/Section.h>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ class EpubReaderActivity final : public Activity {
|
|||||||
std::unique_ptr<Section> section = nullptr;
|
std::unique_ptr<Section> section = nullptr;
|
||||||
int currentSpineIndex = 0;
|
int currentSpineIndex = 0;
|
||||||
int nextPageNumber = 0;
|
int nextPageNumber = 0;
|
||||||
|
std::optional<uint16_t> pendingPageJump;
|
||||||
// Set when navigating to a footnote href with a fragment (e.g. #note1).
|
// Set when navigating to a footnote href with a fragment (e.g. #note1).
|
||||||
// Cleared on the next render after the new section loads and resolves it to a page.
|
// Cleared on the next render after the new section loads and resolves it to a page.
|
||||||
std::string pendingAnchor;
|
std::string pendingAnchor;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <esp_sntp.h>
|
#include <esp_sntp.h>
|
||||||
|
|
||||||
|
#include "Epub/Section.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "KOReaderDocumentId.h"
|
#include "KOReaderDocumentId.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
@@ -14,6 +15,16 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
CrossPointPosition makeLocalPositionWithParagraph(const int spineIndex, const int page, const int totalPages,
|
||||||
|
const std::optional<uint16_t>& paragraphIndex) {
|
||||||
|
CrossPointPosition pos = {spineIndex, page, totalPages};
|
||||||
|
if (paragraphIndex.has_value()) {
|
||||||
|
pos.paragraphIndex = *paragraphIndex;
|
||||||
|
pos.hasParagraphIndex = true;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
void syncTimeWithNTP() {
|
void syncTimeWithNTP() {
|
||||||
// Stop SNTP if already running (can't reconfigure while running)
|
// Stop SNTP if already running (can't reconfigure while running)
|
||||||
if (esp_sntp_enabled()) {
|
if (esp_sntp_enabled()) {
|
||||||
@@ -135,8 +146,21 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine);
|
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine);
|
||||||
|
|
||||||
|
// If XPath carried a paragraph index, refine the page using the section cache's
|
||||||
|
// per-page paragraph LUT instead of anchor matching.
|
||||||
|
if (remotePosition.hasParagraphIndex) {
|
||||||
|
Section tempSection(epub, remotePosition.spineIndex, renderer);
|
||||||
|
const auto paragraphPage = tempSection.getPageForParagraphIndex(remotePosition.paragraphIndex);
|
||||||
|
if (paragraphPage.has_value()) {
|
||||||
|
LOG_DBG("KOSync", "Paragraph %u resolved to page %d (was %d)", remotePosition.paragraphIndex, *paragraphPage,
|
||||||
|
remotePosition.pageNumber);
|
||||||
|
remotePosition.pageNumber = *paragraphPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate local progress in KOReader format (for display)
|
// Calculate local progress in KOReader format (for display)
|
||||||
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
|
CrossPointPosition localPos =
|
||||||
|
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
|
||||||
localProgress = ProgressMapper::toKOReader(epub, localPos);
|
localProgress = ProgressMapper::toKOReader(epub, localPos);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -162,7 +186,8 @@ void KOReaderSyncActivity::performUpload() {
|
|||||||
requestUpdateAndWait();
|
requestUpdateAndWait();
|
||||||
|
|
||||||
// Convert current position to KOReader format
|
// Convert current position to KOReader format
|
||||||
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
|
CrossPointPosition localPos =
|
||||||
|
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
|
||||||
KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos);
|
KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos);
|
||||||
|
|
||||||
KOReaderProgress progress;
|
KOReaderProgress progress;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include "KOReaderSyncClient.h"
|
#include "KOReaderSyncClient.h"
|
||||||
#include "ProgressMapper.h"
|
#include "ProgressMapper.h"
|
||||||
@@ -22,13 +23,15 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
public:
|
public:
|
||||||
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
|
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
|
||||||
int currentPage, int totalPagesInSpine)
|
int currentPage, int totalPagesInSpine,
|
||||||
|
std::optional<uint16_t> currentParagraphIndex = std::nullopt)
|
||||||
: Activity("KOReaderSync", renderer, mappedInput),
|
: Activity("KOReaderSync", renderer, mappedInput),
|
||||||
epub(epub),
|
epub(epub),
|
||||||
epubPath(epubPath),
|
epubPath(epubPath),
|
||||||
currentSpineIndex(currentSpineIndex),
|
currentSpineIndex(currentSpineIndex),
|
||||||
currentPage(currentPage),
|
currentPage(currentPage),
|
||||||
totalPagesInSpine(totalPagesInSpine),
|
totalPagesInSpine(totalPagesInSpine),
|
||||||
|
currentParagraphIndex(currentParagraphIndex),
|
||||||
remoteProgress{},
|
remoteProgress{},
|
||||||
remotePosition{},
|
remotePosition{},
|
||||||
localProgress{} {}
|
localProgress{} {}
|
||||||
@@ -58,6 +61,7 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
int currentSpineIndex;
|
int currentSpineIndex;
|
||||||
int currentPage;
|
int currentPage;
|
||||||
int totalPagesInSpine;
|
int totalPagesInSpine;
|
||||||
|
std::optional<uint16_t> currentParagraphIndex;
|
||||||
|
|
||||||
State state = WIFI_SELECTION;
|
State state = WIFI_SELECTION;
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
|
|||||||
Reference in New Issue
Block a user