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:
Justin Mitchell
2026-04-20 12:47:41 -05:00
committed by GitHub
co-authored by jpirnay
parent e8645ed92e
commit 302dea1eea
12 changed files with 1058 additions and 118 deletions
+99 -12
View File
@@ -10,10 +10,15 @@
#include "parsers/ChapterHtmlSlimParser.h"
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) +
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
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) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
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");
serialization::writePod(file, SECTION_FILE_VERSION);
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, 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 paragraph LUT offset (patched later)
}
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,
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
size_t lastSlash = localPath.find_last_of('/');
@@ -210,7 +217,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
ChapterHtmlSlimParser visitor(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
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);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -230,12 +239,12 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const uint32_t lutOffset = file.position();
bool hasFailedLutRecords = false;
// Write LUT
for (const uint32_t& pos : lut) {
if (pos == 0) {
for (const auto& entry : lut) {
if (entry.fileOffset == 0) {
hasFailedLutRecords = true;
break;
}
serialization::writePod(file, pos);
serialization::writePod(file, entry.fileOffset);
}
if (hasFailedLutRecords) {
@@ -255,11 +264,18 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, page);
}
// Patch header with final pageCount, lutOffset, and anchorMapOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount));
const uint32_t paragraphLutOffset = file.position();
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, lutOffset);
serialization::writePod(file, anchorMapOffset);
serialization::writePod(file, paragraphLutOffset);
// Explicit close() required: member variable persists beyond function scope
file.close();
if (cssParser) {
@@ -273,7 +289,7 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return nullptr;
}
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3);
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
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();
f.seek(HEADER_SIZE - sizeof(uint32_t));
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
@@ -316,3 +332,74 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
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;
}
+6
View File
@@ -42,4 +42,10 @@ class Section {
// Look up the page number for an anchor id from the section cache file.
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;
}
if (strcmp(name, "p") == 0) {
self->xpathParagraphIndex++;
}
// Extract class, style, and id attributes
std::string classAttr;
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
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->completePageFn(std::move(self->currentPage), self->xpathParagraphIndex);
self->completedPageCount++;
self->currentPage.reset(new Page());
if (!self->currentPage) {
@@ -1066,7 +1070,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
completePageFn(std::move(currentPage));
completePageFn(std::move(currentPage), xpathParagraphIndex);
completedPageCount++;
currentPage.reset();
currentTextBlock.reset();
@@ -1084,7 +1088,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
}
if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage));
completePageFn(std::move(currentPage), xpathParagraphIndex);
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
@@ -25,7 +25,7 @@ class ChapterHtmlSlimParser {
std::shared_ptr<Epub> epub;
const std::string& filepath;
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
int depth = 0;
int skipUntilDepth = INT_MAX;
@@ -74,6 +74,7 @@ class ChapterHtmlSlimParser {
int completedPageCount = 0;
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
uint16_t xpathParagraphIndex = 0;
// Footnote link tracking
bool insideFootnoteLink = false;
@@ -99,7 +100,7 @@ class ChapterHtmlSlimParser {
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
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 std::string& imageBasePath, const uint8_t imageRendering = 0,
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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);
};
+265 -84
View File
@@ -2,113 +2,294 @@
#include <Logging.h>
#include <algorithm>
#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 result;
// Calculate page progress within current spine item
float intraSpineProgress = 0.0f;
if (pos.totalPages > 0) {
intraSpineProgress = static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages);
float intra = (pos.totalPages > 0) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages) : 0.0f;
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
} else {
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
}
// Calculate overall book progress (0.0-1.0)
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath with estimated paragraph position based on page
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());
if (result.xpath.empty()) {
result.xpath = generateXPath(epub, pos.spineIndex, intra);
}
LOG_DBG("PM", "-> KO: 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 KOReaderPosition& koPos,
int currentSpineIndex, int totalPagesInCurrentSpine) {
CrossPointPosition result;
result.spineIndex = 0;
result.pageNumber = 0;
result.totalPages = 0;
CrossPointPosition result{};
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
return result;
}
if (bookSize == 0) 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();
bool spineFound = false;
for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
result.spineIndex = i;
spineFound = true;
break;
}
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 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),
// default to the last spine item so we map to the end of the book instead of the beginning.
if (!spineFound && spineCount > 0) {
result.spineIndex = spineCount - 1;
}
// Estimate page number within the spine item using percentage
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 (xpathSpine >= 0 && xpathSpine < spineCount) {
result.spineIndex = xpathSpine;
} else {
for (int i = 0; i < spineCount; i++) {
if (epub->getCumulativeSpineItemSize(i) >= targetBytes) {
result.spineIndex = i;
break;
}
}
}
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) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
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 (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,
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
result.pageNumber = std::max(0, std::min(static_cast<int>(intra * result.totalPages), result.totalPages - 1));
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;
}
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
// Use 0-based DocFragment indices for KOReader
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
// Avoid specifying paragraph numbers as they may not exist in the target document
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
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;
}
+10 -7
View File
@@ -8,9 +8,11 @@
* 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
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
};
/**
@@ -59,9 +61,10 @@ class ProgressMapper {
private:
/**
* Generate XPath for KOReader compatibility.
* Format: /body/DocFragment[spineIndex+1]/body
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
* 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(int spineIndex, int pageNumber, int totalPages);
static std::string generateXPath(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
};
+40 -7
View File
@@ -10,6 +10,8 @@
#include <Logging.h>
#include <esp_system.h>
#include <limits>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
@@ -63,6 +65,13 @@ void EpubReaderActivity::onEnter() {
if (dataSize == 4 || dataSize == 6) {
currentSpineIndex = data[0] + (data[1] << 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;
LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber);
}
@@ -186,7 +195,8 @@ void EpubReaderActivity::loop() {
onGoHome();
} else {
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
nextPageNumber = 0;
pendingPageJump = std::numeric_limits<uint16_t>::max();
requestUpdate();
}
return;
@@ -390,11 +400,19 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
case EpubReaderMenuActivity::MenuAction::SYNC: {
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
const int currentPage = section ? section->currentPage : nextPageNumber;
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(
std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex,
currentPage, totalPages),
currentPage, totalPages, paragraphIndex),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& sync = std::get<SyncResult>(result.data);
@@ -402,6 +420,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
cachedChapterTotalPageCount = 0; // Prevent rescaling sync page
pendingPageJump.reset();
saveProgress(currentSpineIndex, nextPageNumber, 0);
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
{
RenderLock lock(*this);
nextPageNumber = UINT16_MAX;
nextPageNumber = 0;
pendingPageJump = std::numeric_limits<uint16_t>::max();
currentSpineIndex--;
section.reset();
}
@@ -566,10 +588,21 @@ void EpubReaderActivity::render(RenderLock&& lock) {
LOG_DBG("ERS", "Cache found, skipping build...");
}
if (nextPageNumber == UINT16_MAX) {
section->currentPage = section->pageCount - 1;
if (pendingPageJump.has_value()) {
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
section->currentPage = section->pageCount - 1;
} else {
section->currentPage = *pendingPageJump;
}
pendingPageJump.reset();
} else {
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()) {
@@ -3,6 +3,8 @@
#include <Epub/FootnoteEntry.h>
#include <Epub/Section.h>
#include <optional>
#include "EpubReaderMenuActivity.h"
#include "activities/Activity.h"
@@ -11,6 +13,7 @@ class EpubReaderActivity final : public Activity {
std::unique_ptr<Section> section = nullptr;
int currentSpineIndex = 0;
int nextPageNumber = 0;
std::optional<uint16_t> pendingPageJump;
// 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.
std::string pendingAnchor;
+27 -2
View File
@@ -6,6 +6,7 @@
#include <WiFi.h>
#include <esp_sntp.h>
#include "Epub/Section.h"
#include "KOReaderCredentialStore.h"
#include "KOReaderDocumentId.h"
#include "MappedInputManager.h"
@@ -14,6 +15,16 @@
#include "fontIds.h"
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() {
// Stop SNTP if already running (can't reconfigure while running)
if (esp_sntp_enabled()) {
@@ -135,8 +146,21 @@ void KOReaderSyncActivity::performSync() {
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
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)
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
CrossPointPosition localPos =
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
localProgress = ProgressMapper::toKOReader(epub, localPos);
{
@@ -162,7 +186,8 @@ void KOReaderSyncActivity::performUpload() {
requestUpdateAndWait();
// Convert current position to KOReader format
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
CrossPointPosition localPos =
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos);
KOReaderProgress progress;
+5 -1
View File
@@ -3,6 +3,7 @@
#include <functional>
#include <memory>
#include <optional>
#include "KOReaderSyncClient.h"
#include "ProgressMapper.h"
@@ -22,13 +23,15 @@ class KOReaderSyncActivity final : public Activity {
public:
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
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),
epub(epub),
epubPath(epubPath),
currentSpineIndex(currentSpineIndex),
currentPage(currentPage),
totalPagesInSpine(totalPagesInSpine),
currentParagraphIndex(currentParagraphIndex),
remoteProgress{},
remotePosition{},
localProgress{} {}
@@ -58,6 +61,7 @@ class KOReaderSyncActivity final : public Activity {
int currentSpineIndex;
int currentPage;
int totalPagesInSpine;
std::optional<uint16_t> currentParagraphIndex;
State state = WIFI_SELECTION;
std::string statusMessage;