mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-04-29 10:26:52 -07:00
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"
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 <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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user