diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 569646a4..6ba44ac7 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -310,6 +310,7 @@ STR_PROGRESS_FOUND: "Progress found!" STR_REMOTE_LABEL: "Remote:" STR_LOCAL_LABEL: "Local:" STR_PAGE_OVERALL_FORMAT: "Page %d, %.2f%% overall" +STR_PERCENT_OVERALL_FORMAT: " %.2f%% overall" STR_PAGE_TOTAL_OVERALL_FORMAT: "Page %d/%d, %.2f%% overall" STR_DEVICE_FROM_FORMAT: " From: %s" STR_APPLY_REMOTE: "Apply remote progress" diff --git a/lib/KOReaderSync/BookXPath.cpp b/lib/KOReaderSync/BookXPath.cpp new file mode 100644 index 00000000..2174edd3 --- /dev/null +++ b/lib/KOReaderSync/BookXPath.cpp @@ -0,0 +1,366 @@ +#include "BookXPath.h" + +#include +#include +#include + +#include +#include + +namespace { + +using freeink::book::Arena; +using freeink::book::BookStatus; +using freeink::book::XmlHandler; +using freeink::book::XmlSax; + +// XmlSax parse working set: inflate window + parse chunk (+ expat's own +// bounded heap). One transient allocation per mapping call. +constexpr size_t kParseScratchSize = 64 * 1024; + +constexpr int kMaxDepth = 24; // element ancestry below +constexpr int kMaxSiblingTags = 24; // distinct child tag names tracked per level +constexpr int kMaxSteps = 16; // parsed xpath ancestry steps + +const char* localName(const char* qname) { + const char* colon = strrchr(qname, ':'); + return colon != nullptr ? colon + 1 : qname; +} + +bool isSuppressedElement(const char* local) { + return strcmp(local, "head") == 0 || strcmp(local, "style") == 0 || strcmp(local, "script") == 0 || + strcmp(local, "title") == 0; +} + +// ChapterLayout's block list — these flush the paragraph accumulator. +bool isBlockElement(const char* local) { + static const char* kBlocks[] = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", + "li", "div", "section", "article", "figure", "aside", "figcaption", "ul", + "ol", "table", "tr", "td", "th", "dt", "dd"}; + for (const char* b : kBlocks) { + if (strcmp(local, b) == 0) return true; + } + return false; +} + +uint32_t fnvHash(const char* s) { + uint32_t hash = 2166136261u; + while (*s != '\0') { + hash ^= static_cast(*s++); + hash *= 16777619u; + } + return hash; +} + +struct AncestryLevel { + char tag[16]; + int siblingIndex; // 1-based, among same-tag siblings + // Child tag occurrence counts (for the level BELOW this one). + uint32_t childTagHashes[kMaxSiblingTags]; + int childTagCounts[kMaxSiblingTags]; + int childTagKinds; +}; + +struct XPathStep { + char tag[16]; + int siblingIndex; +}; + +// Parses the element steps between "]/body/" and the terminal "/text()..." +// or ".NN" of a KOReader xpath. Returns the step count, 0 on failure. +int parseXPathSteps(const std::string& xpath, XPathStep* steps, int* charOffsetOut) { + *charOffsetOut = 0; + static const char kFrag[] = "/body/DocFragment["; + const size_t fragPos = xpath.find(kFrag); + if (fragPos == std::string::npos) return 0; + const size_t closeBracket = xpath.find(']', fragPos + strlen(kFrag)); + if (closeBracket == std::string::npos) return 0; + static const char kBody[] = "/body/"; + if (xpath.compare(closeBracket + 1, strlen(kBody), kBody) != 0) return 0; + size_t pos = closeBracket + 1 + strlen(kBody); + + // Terminal: "/text()[K].N" or "/text().N" or ".N" directly on the element. + size_t stepsEnd = xpath.rfind("/text()"); + if (stepsEnd == std::string::npos || stepsEnd < pos) { + const size_t dot = xpath.rfind('.'); + stepsEnd = (dot != std::string::npos && dot > pos) ? dot : xpath.size(); + } + const size_t dot = xpath.rfind('.'); + if (dot != std::string::npos && dot + 1 < xpath.size()) { + int val = 0; + bool numeric = true; + for (size_t i = dot + 1; i < xpath.size(); ++i) { + if (xpath[i] < '0' || xpath[i] > '9') { + numeric = false; + break; + } + val = val * 10 + (xpath[i] - '0'); + } + if (numeric) *charOffsetOut = val; + } + if (stepsEnd <= pos) return 0; + + int count = 0; + while (pos < stepsEnd && count < kMaxSteps) { + const size_t slash = xpath.find('/', pos); + const size_t segEnd = (slash != std::string::npos && slash < stepsEnd) ? slash : stepsEnd; + XPathStep& step = steps[count]; + const size_t bracket = xpath.find('[', pos); + const size_t nameEnd = (bracket != std::string::npos && bracket < segEnd) ? bracket : segEnd; + const size_t nameLen = nameEnd - pos; + if (nameLen == 0 || nameLen >= sizeof(step.tag)) return 0; + memcpy(step.tag, xpath.c_str() + pos, nameLen); + step.tag[nameLen] = '\0'; + step.siblingIndex = 1; + if (bracket != std::string::npos && bracket < segEnd) { + const size_t close = xpath.find(']', bracket + 1); + if (close == std::string::npos || close > segEnd) return 0; + int idx = 0; + for (size_t i = bracket + 1; i < close; ++i) { + if (xpath[i] < '0' || xpath[i] > '9') return 0; + idx = idx * 10 + (xpath[i] - '0'); + } + step.siblingIndex = idx > 0 ? idx : 1; + } + ++count; + pos = (slash != std::string::npos && slash < stepsEnd) ? slash + 1 : stepsEnd; + } + return count; +} + +// Streams the chapter, replicating ChapterLayout's extracted-text accounting +// (which defines charStart) while tracking DOM element ancestry with per-tag +// sibling indices. Runs in one of two modes: +// Locate: find the ancestry containing character offset `target` +// Resolve: find the character offset where a given ancestry begins +class XPathScanner final : public XmlHandler { + public: + enum class Mode { Locate, Resolve }; + + XPathScanner(const Mode mode, const uint32_t targetChar, const XPathStep* steps, const int stepCount) + : mode_(mode), targetChar_(targetChar), steps_(steps), stepCount_(stepCount) { + memset(&root_, 0, sizeof(root_)); + } + + void onStartElement(const char* name, const char** /*atts*/) override { + const char* local = localName(name); + if (isSuppressedElement(local)) { + ++suppress_; + return; + } + if (strcmp(local, "body") == 0) { + inBody_ = true; + return; + } + if (!inBody_) return; + + // Ancestry: sibling index among same-tag children of the current parent. + if (depth_ < kMaxDepth) { + AncestryLevel& parent = depth_ == 0 ? root_ : stack_[depth_ - 1]; + AncestryLevel& self = stack_[depth_]; + snprintf(self.tag, sizeof(self.tag), "%s", local); + self.siblingIndex = bumpChildCount(parent, local); + self.childTagKinds = 0; + } + ++depth_; + + if (suppress_ > 0) return; + if (isBlockElement(local)) { + flushParagraph(); + } else if (strcmp(local, "br") == 0) { + ++parChars_; // appendRaw('\n') — bypasses whitespace collapse + checkLocate(); + } else if (strcmp(local, "hr") == 0 || strcmp(local, "img") == 0 || strcmp(local, "image") == 0) { + flushParagraph(); + } + + if (mode_ == Mode::Resolve && !resolved_ && depth_ <= kMaxDepth && depth_ == stepCount_ && ancestryMatchesSteps()) { + // Element start + the xpath's text offset. A pending collapsed space + // materializes before this element's first character, so it counts. + resolvedChar_ = charBase_ + parChars_ + (pendingSpace_ ? 1u : 0u) + targetChar_; + resolved_ = true; + stopParse = true; + } + } + + void onEndElement(const char* name) override { + const char* local = localName(name); + if (isSuppressedElement(local)) { + if (suppress_ > 0) --suppress_; + return; + } + if (strcmp(local, "body") == 0) { + flushParagraph(); + inBody_ = false; + return; + } + if (!inBody_) return; + if (depth_ > 0) --depth_; + if (suppress_ == 0 && isBlockElement(local)) flushParagraph(); + } + + void onText(const char* text, const int len) override { + if (!inBody_ || suppress_ > 0 || stopParse) return; + for (int i = 0; i < len; ++i) { + const char c = text[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pendingSpace_ = parChars_ > 0; + } else { + if (pendingSpace_) { + ++parChars_; // the collapsed space materializes + pendingSpace_ = false; + checkLocate(); + if (stopParse) return; + } + if ((static_cast(c) & 0xC0) != 0x80) { + ++parChars_; // one codepoint (lead or ASCII byte) + checkLocate(); + if (stopParse) return; + } + } + } + } + + bool located() const { return located_; } + bool resolved() const { return resolved_; } + uint32_t resolvedChar() const { return resolvedChar_; } + uint32_t locatedOffset() const { return locatedOffset_; } + int locatedDepth() const { return locatedDepth_; } + const AncestryLevel* locatedStack() const { return locatedStack_; } + + private: + int bumpChildCount(AncestryLevel& parent, const char* tag) { + const uint32_t hash = fnvHash(tag); + for (int i = 0; i < parent.childTagKinds; ++i) { + if (parent.childTagHashes[i] == hash) return ++parent.childTagCounts[i]; + } + if (parent.childTagKinds < kMaxSiblingTags) { + parent.childTagHashes[parent.childTagKinds] = hash; + parent.childTagCounts[parent.childTagKinds] = 1; + ++parent.childTagKinds; + return 1; + } + return 1; // tag-table overflow: index degrades to 1 (rare, deep soup) + } + + void flushParagraph() { + charBase_ += parChars_; + parChars_ = 0; + pendingSpace_ = false; + } + + // Locate mode: the character at index `targetChar_` was just appended — + // capture the current ancestry and the offset within this paragraph. + void checkLocate() { + if (mode_ != Mode::Locate || located_) return; + if (charBase_ + parChars_ > targetChar_) { + locatedDepth_ = depth_ <= kMaxDepth ? depth_ : kMaxDepth; + memcpy(locatedStack_, stack_, sizeof(AncestryLevel) * locatedDepth_); + locatedOffset_ = targetChar_ >= charBase_ ? targetChar_ - charBase_ : 0; + located_ = true; + stopParse = true; + } + } + + bool ancestryMatchesSteps() const { + for (int i = 0; i < stepCount_; ++i) { + if (strcmp(stack_[i].tag, steps_[i].tag) != 0 || stack_[i].siblingIndex != steps_[i].siblingIndex) { + return false; + } + } + return true; + } + + const Mode mode_; + const uint32_t targetChar_; + const XPathStep* steps_; + const int stepCount_; + + AncestryLevel root_; + AncestryLevel stack_[kMaxDepth]; + int depth_ = 0; + int suppress_ = 0; + bool inBody_ = false; + + uint32_t charBase_ = 0; + uint32_t parChars_ = 0; + bool pendingSpace_ = false; + + bool located_ = false; + uint32_t locatedOffset_ = 0; + int locatedDepth_ = 0; + AncestryLevel locatedStack_[kMaxDepth]; + + bool resolved_ = false; + uint32_t resolvedChar_ = 0; +}; + +bool runScan(freeink::book::BookSource& source, const freeink::book::ZipEntry& entry, XPathScanner& scanner) { + auto scratchBuf = makeUniqueNoThrow(kParseScratchSize); + if (!scratchBuf) { + LOG_ERR("KOXP", "OOM: xpath scan scratch (%u B)", static_cast(kParseScratchSize)); + return false; + } + Arena scratch(scratchBuf.get(), kParseScratchSize); + const BookStatus st = XmlSax::parseEntry(source, entry, scratch, scanner, /*filterHtmlEntities=*/true); + return st == BookStatus::Ok; +} + +} // namespace + +namespace BookXPath { + +std::string xpathForCharStart(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/, + const freeink::book::ZipEntry& entry, const int spineIndex, const uint32_t charStart) { + const std::string chapterOnly = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]"; + + XPathScanner scanner(XPathScanner::Mode::Locate, charStart, nullptr, 0); + if (!runScan(source, entry, scanner) || !scanner.located()) { + return chapterOnly; + } + + std::string xpath = chapterOnly + "/body"; + const AncestryLevel* stack = scanner.locatedStack(); + for (int i = 0; i < scanner.locatedDepth(); ++i) { + xpath += "/"; + xpath += stack[i].tag; + xpath += "[" + std::to_string(stack[i].siblingIndex) + "]"; + } + xpath += "/text()." + std::to_string(scanner.locatedOffset()); + return xpath; +} + +bool charStartForXpath(freeink::book::BookSource& source, const freeink::book::ZipCatalog& /*zip*/, + const freeink::book::ZipEntry& entry, const std::string& xpath, uint32_t* charStartOut) { + *charStartOut = 0; + XPathStep steps[kMaxSteps]; + int charOffset = 0; + const int stepCount = parseXPathSteps(xpath, steps, &charOffset); + if (stepCount == 0) { + // "/body/DocFragment[N]" with no element steps = chapter start. + return spineIndexForXpath(xpath) >= 0; + } + + XPathScanner scanner(XPathScanner::Mode::Resolve, static_cast(charOffset), steps, stepCount); + if (!runScan(source, entry, scanner) || !scanner.resolved()) { + return false; + } + *charStartOut = scanner.resolvedChar(); + return true; +} + +int spineIndexForXpath(const std::string& xpath) { + static const char kFrag[] = "/body/DocFragment["; + const size_t pos = xpath.find(kFrag); + if (pos == std::string::npos) return -1; + int val = 0; + bool any = false; + for (size_t i = pos + strlen(kFrag); i < xpath.size() && xpath[i] != ']'; ++i) { + if (xpath[i] < '0' || xpath[i] > '9') return -1; + val = val * 10 + (xpath[i] - '0'); + any = true; + } + return any && val > 0 ? val - 1 : -1; +} + +} // namespace BookXPath diff --git a/lib/KOReaderSync/BookXPath.h b/lib/KOReaderSync/BookXPath.h new file mode 100644 index 00000000..e46e72e4 --- /dev/null +++ b/lib/KOReaderSync/BookXPath.h @@ -0,0 +1,39 @@ +#pragma once + +// KOReader xpath <-> FreeInkBook character offset, via one streaming SAX pass +// over the chapter's XHTML (no DOM, no pagination — chapter character offsets +// are layout-independent, so neither direction needs the page cache). +// +// The text accounting mirrors ChapterLayout exactly (suppressed head/style/ +// script/title, body gating, whitespace collapse,
newlines, block +// flushes), so the offsets these functions produce address the same +// characters the engine's page records anchor on. Known approximation: +// display:none content is counted here but skipped by layout when the book's +// CSS hides it — a paragraph-level drift on such books, corrected by the +// percentage fallback. + +#include + +#include +#include + +namespace BookXPath { + +// Real-ancestry KOReader xpath for a chapter character offset: +// "/body/DocFragment[N]/body/div[2]/p[4]/text().17". Falls back to the +// chapter-level "/body/DocFragment[N]" when the offset lies past the text or +// the parse fails. +std::string xpathForCharStart(freeink::book::BookSource& source, const freeink::book::ZipCatalog& zip, + const freeink::book::ZipEntry& entry, int spineIndex, uint32_t charStart); + +// Resolves a KOReader xpath's element ancestry to the chapter character +// offset where that element's text begins (plus the xpath's text offset). +// Returns false when the ancestry cannot be matched — caller falls back to +// the synced percentage. +bool charStartForXpath(freeink::book::BookSource& source, const freeink::book::ZipCatalog& zip, + const freeink::book::ZipEntry& entry, const std::string& xpath, uint32_t* charStartOut); + +// The 0-based spine index from "/body/DocFragment[N]/...", or -1. +int spineIndexForXpath(const std::string& xpath); + +} // namespace BookXPath diff --git a/lib/KOReaderSync/KOReaderPosition.h b/lib/KOReaderSync/KOReaderPosition.h new file mode 100644 index 00000000..bb9b5bca --- /dev/null +++ b/lib/KOReaderSync/KOReaderPosition.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// A KOReader-protocol reading position: an XPath-like locator into the book's +// DOM plus a whole-book percentage. The percentage is the robust cross-device +// mechanism; the xpath adds paragraph precision for readers that honor it. +struct SavedProgressPosition { + std::string xpath; // e.g. "/body/DocFragment[8]/body/div[1]/p[42]/text().17" + float percentage; // 0.0 to 1.0 across the whole book +}; diff --git a/lib/KOReaderSync/ProgressMapper.h b/lib/KOReaderSync/ProgressMapper.h index d48da2e3..ebd1bba7 100644 --- a/lib/KOReaderSync/ProgressMapper.h +++ b/lib/KOReaderSync/ProgressMapper.h @@ -19,13 +19,7 @@ struct CrossPointPosition { char xpathAnchorId[64] = {}; // First captured inside the matched XPath element }; -/** - * Progress position representation. - */ -struct SavedProgressPosition { - std::string xpath; // XPath-like progress string - float percentage; // Progress percentage (0.0 to 1.0) -}; +#include "KOReaderPosition.h" // SavedProgressPosition /** * Maps between CrossPoint and SavedProgress position formats, such as those used by KOReader. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e5929cf8..d568072d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,5 +1,6 @@ #include "EpubReaderActivity.h" +#include #include #include #include @@ -55,9 +56,7 @@ bool isInReadFolder(const std::string& path) { return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/'; } -std::string cacheDirForBook(const std::string& path) { - return "/.crosspoint/epub_" + std::to_string(std::hash{}(path)); -} +using EpubReaderUtils::cacheDirForBook; // Display fallback when the OPF carries no title: the bare filename. std::string filenameTitle(const std::string& path) { @@ -550,9 +549,19 @@ bool EpubReaderActivity::launchKOReaderSync() { const int totalPages = paginator.chapterReady() ? static_cast(paginator.pageCount()) : 0; // Pre-compute the local KOReader position and chapter name while the book - // is still open. The synthetic xpath carries the chapter; the whole-book - // percentage is the primary sync mechanism. - SavedProgressPosition localKoPos{syntheticXPath(currentSpineIndex), currentBookFraction()}; + // is still open. A streaming scan turns the current page's character offset + // into a real-ancestry xpath so KOReader devices land on the paragraph; + // the whole-book percentage is the robust fallback. + std::string localXPath = syntheticXPath(currentSpineIndex); + if (!paginator.isTxt()) { + const freeink::book::ManifestItem* item = paginator.book().spineItem(currentSpineIndex); + const freeink::book::ZipEntry* entry = item != nullptr ? paginator.book().zip().find(item->href) : nullptr; + if (entry != nullptr) { + localXPath = BookXPath::xpathForCharStart(*paginator.bookSource(), paginator.book().zip(), *entry, + currentSpineIndex, lastCharStart); + } + } + SavedProgressPosition localKoPos{std::move(localXPath), currentBookFraction()}; const int tocIdx = paginator.tocIndexForSpine(currentSpineIndex); std::string localChapterName = tocIdx >= 0 ? paginator.tocItem(tocIdx).title : ""; diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index d93e467b..6da1527e 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -9,6 +9,12 @@ namespace EpubReaderUtils { +// Per-book cache directory, keyed by a hash of the book's path (moving or +// renaming the file re-keys it — the long-standing CrossPoint convention). +inline std::string cacheDirForBook(const std::string& path) { + return "/.crosspoint/epub_" + std::to_string(std::hash{}(path)); +} + // Reader progress, FreeInkBook locator model. `charStart` (chapter character // offset) is layout-parameter independent — it restores exactly across font, // margin, spacing, and orientation changes. When a position is known only as diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 9762c8c2..605896dd 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -1,5 +1,6 @@ #include "KOReaderSyncActivity.h" +#include #include #include #include @@ -9,9 +10,7 @@ #include #include -#include -#include "Epub/Section.h" #include "EpubReaderUtils.h" #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" @@ -51,36 +50,66 @@ void syncTimeWithNTP() { } } // namespace -void KOReaderSyncActivity::ensureEpubLoaded() { - if (!epub) { - LOG_DBG("KOSync", "Loading epub for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap()); - epub = std::make_shared(epubPath, "/.crosspoint"); - epub->setupCacheDir(); - // Load metadata only (no CSS needed for progress mapping, don't rebuild if cache is missing). - if (!epub->load(false, true)) { - LOG_ERR("KOSync", "Failed to load epub for progress mapping"); - epub.reset(); - return; - } - LOG_DBG("KOSync", "Epub loaded (heap: %u)", (unsigned)ESP.getFreeHeap()); +bool KOReaderSyncActivity::ensureBookLoaded() { + if (paginator.isOpen()) return true; + LOG_DBG("KOSync", "Loading book for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap()); + if (!paginator.open(epubPath, EpubReaderUtils::cacheDirForBook(epubPath), renderer)) { + LOG_ERR("KOSync", "Failed to open book for progress mapping"); + return false; + } + LOG_DBG("KOSync", "Book loaded (heap: %u)", (unsigned)ESP.getFreeHeap()); + return true; +} + +// Resolve the fetched remote (xpath, percentage) into the new-engine locator: +// spine from the xpath's DocFragment, character offset by matching the +// xpath's element ancestry in a streaming scan of that chapter. Any failure +// falls back to the percentage (mapped through spine byte weights). +void KOReaderSyncActivity::resolveRemotePosition() { + remotePosition = RemotePosition{}; + + int spine = BookXPath::spineIndexForXpath(remoteProgress.progress); + float chapterFraction = 0.0f; + if (spine < 0 || spine >= static_cast(paginator.spineCount())) { + spine = paginator.spineForBookFraction(remoteProgress.percentage, &chapterFraction); + remotePosition.spineIndex = spine; + remotePosition.chapterFraction = chapterFraction; + return; + } + remotePosition.spineIndex = spine; + + // Percentage-derived landing within the chapter as the fallback. + const float chapterStart = paginator.bookProgress(spine, 0.0f); + const float chapterEnd = paginator.bookProgress(spine, 1.0f); + remotePosition.chapterFraction = + chapterEnd > chapterStart + ? std::clamp((remoteProgress.percentage - chapterStart) / (chapterEnd - chapterStart), 0.0f, 1.0f) + : 0.0f; + + const freeink::book::ManifestItem* item = paginator.book().spineItem(spine); + const freeink::book::ZipEntry* entry = item != nullptr ? paginator.book().zip().find(item->href) : nullptr; + if (entry == nullptr) return; + uint32_t charStart = 0; + if (BookXPath::charStartForXpath(*paginator.bookSource(), paginator.book().zip(), *entry, remoteProgress.progress, + &charStart)) { + remotePosition.charStart = charStart; + remotePosition.hasCharStart = true; + LOG_DBG("KOSync", "Remote xpath resolved: spine %d char %u", spine, static_cast(charStart)); + } else { + LOG_DBG("KOSync", "Remote xpath ancestry not matched; using percentage fallback"); } } -void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) { - // epub is guaranteed non-null here: ensureEpubLoaded() was called in performSync() before - // SHOWING_RESULT state is entered, and this method is only called from that state. - assert(epub); - // The reader restores positions by chapter character offset; the remote - // position arrives as an estimated (page, totalPages), so persist it as a - // chapter fraction that resolves against totalChars() once the chapter's - // page cache opens. - const int totalPages = remotePosition.totalPages; - const uint32_t fractionQ16 = - (totalPages > 0 && page > 0 && page <= totalPages) - ? (static_cast(page) << 16) / static_cast(totalPages) - : 0; - if (!EpubReaderUtils::saveProgress(epub->getCachePath(), static_cast(spineIndex), - EpubReaderUtils::kNoCharStart, fractionQ16)) { +void KOReaderSyncActivity::saveProgressAndReturn() { + const uint32_t fractionQ16 = static_cast(std::clamp(remotePosition.chapterFraction, 0.0f, 1.0f) * 65536.0f); + const bool ok = + remotePosition.hasCharStart + ? EpubReaderUtils::saveProgress(EpubReaderUtils::cacheDirForBook(epubPath), + static_cast(remotePosition.spineIndex), remotePosition.charStart) + : EpubReaderUtils::saveProgress(EpubReaderUtils::cacheDirForBook(epubPath), + static_cast(remotePosition.spineIndex), + EpubReaderUtils::kNoCharStart, fractionQ16); + if (!ok) { { RenderLock lock(*this); state = SYNC_FAILED; @@ -171,10 +200,9 @@ void KOReaderSyncActivity::performSync() { return; } - // Epub was released before sync to free RAM for the TLS handshake — reload it now. + // The book was released before sync to free RAM for the TLS handshake — reload it now. hasRemoteProgress = true; - ensureEpubLoaded(); - if (!epub) { + if (!ensureBookLoaded()) { { RenderLock lock(*this); state = SYNC_FAILED; @@ -184,8 +212,7 @@ void KOReaderSyncActivity::performSync() { return; } - SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; - remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); + resolveRemotePosition(); // localProgress was pre-computed in EpubReaderActivity before the Epub was released. { @@ -308,10 +335,10 @@ void KOReaderSyncActivity::render(RenderLock&&) { top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); - // Remote chapter name requires Epub (loaded lazily in performSync before this state). - const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); + // Remote chapter name requires the book (loaded lazily in performSync before this state). + const int remoteTocIndex = paginator.tocIndexForSpine(remotePosition.spineIndex); const std::string remoteChapter = - (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title + (remoteTocIndex >= 0) ? paginator.tocItem(remoteTocIndex).title : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); // Local chapter name was pre-computed before Epub was released. const std::string localChapter = @@ -323,9 +350,10 @@ void KOReaderSyncActivity::render(RenderLock&&) { char remoteChapterStr[128]; snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 65, remoteChapterStr); + // No page number: the remote position is a character locator; its page is + // whatever the local pagination derives when the reader reopens. char remotePageStr[64]; - snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1, - remoteProgress.percentage * 100); + snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PERCENT_OVERALL_FORMAT), remoteProgress.percentage * 100); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 90, remotePageStr); if (!remoteProgress.device.empty()) { @@ -420,7 +448,7 @@ void KOReaderSyncActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (selectedOption == 0) { - saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber); + saveProgressAndReturn(); } else if (selectedOption == 1) { // Upload local progress performUpload(); diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index b3b82d01..06f9f3d5 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -1,12 +1,11 @@ #pragma once -#include +#include -#include -#include #include +#include +#include "BookPaginator.h" #include "KOReaderSyncClient.h" -#include "ProgressMapper.h" #include "activities/Activity.h" /** @@ -18,6 +17,11 @@ * 3. Fetch remote progress * 4. Show comparison and options (Apply/Upload) * 5. Apply or upload progress + * + * Positions are FreeInkBook locators: remote xpaths resolve to a chapter + * character offset (BookXPath) when their element ancestry matches, else to + * a chapter fraction from the synced percentage. Either lands exactly via + * pageForChar() when the reader reopens. */ class KOReaderSyncActivity final : public Activity { public: @@ -33,7 +37,6 @@ class KOReaderSyncActivity final : public Activity { currentParagraphIndex(currentParagraphIndex), localChapterName(std::move(localChapterName)), remoteProgress{}, - remotePosition{}, localProgress(std::move(localKoPos)) {} void onEnter() override; @@ -55,7 +58,15 @@ class KOReaderSyncActivity final : public Activity { NO_CREDENTIALS }; - std::shared_ptr epub; // null until lazy-loaded after TLS in performSync() + // Remote position resolved into the new-engine locator model. + struct RemotePosition { + int spineIndex = 0; + uint32_t charStart = 0; + bool hasCharStart = false; // xpath ancestry matched exactly + float chapterFraction = 0; // percentage-derived fallback landing + }; + + BookPaginator paginator; // closed until lazy-loaded after TLS in performSync() std::string epubPath; std::string localChapterName; int currentSpineIndex; @@ -70,9 +81,9 @@ class KOReaderSyncActivity final : public Activity { // Remote progress data bool hasRemoteProgress = false; KOReaderProgress remoteProgress; - CrossPointPosition remotePosition; + RemotePosition remotePosition; - // Local progress as KOReader format (pre-computed before Epub was released) + // Local progress as KOReader format (pre-computed before the book was released) SavedProgressPosition localProgress; // Selection in result screen (0=Apply, 1=Upload) @@ -87,7 +98,8 @@ class KOReaderSyncActivity final : public Activity { void onWifiSelectionComplete(bool success); void performSync(); void performUpload(); - void ensureEpubLoaded(); - void saveProgressAndReturn(int spineIndex, int page); + bool ensureBookLoaded(); + void resolveRemotePosition(); + void saveProgressAndReturn(); void returnToReader(); }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 951addfe..b770cf0a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -44,3 +44,4 @@ add_subdirectory(differential_rounding) add_subdirectory(hyphenation_eval) add_subdirectory(utf8_compose) add_subdirectory(cpfont_adapter) +add_subdirectory(book_xpath) diff --git a/test/book_xpath/BookXPathTest.cpp b/test/book_xpath/BookXPathTest.cpp new file mode 100644 index 00000000..34a137f4 --- /dev/null +++ b/test/book_xpath/BookXPathTest.cpp @@ -0,0 +1,165 @@ +#include + +#include +#include +#include + +#include "BookXPath.h" + +// ============================================================================ +// In-memory stored-ZIP fixture: BookXPath streams chapters through the +// engine's ZipEntryReader, which reads the entry's local file header first, +// so the buffer must be a structurally valid (uncompressed) zip member. +// ============================================================================ + +namespace { + +using freeink::book::BookSource; +using freeink::book::ZipCatalog; +using freeink::book::ZipEntry; + +class MemorySource : public BookSource { + public: + explicit MemorySource(std::vector bytes) : bytes_(std::move(bytes)) {} + int32_t readAt(uint64_t offset, void* dst, uint32_t len) override { + if (offset >= bytes_.size()) return 0; + const uint32_t n = std::min(len, bytes_.size() - offset); + memcpy(dst, bytes_.data() + offset, n); + return static_cast(n); + } + uint64_t size() const override { return bytes_.size(); } + + private: + std::vector bytes_; +}; + +void putU16(std::vector& v, uint16_t x) { + v.push_back(x & 0xFF); + v.push_back(x >> 8); +} +void putU32(std::vector& v, uint32_t x) { + for (int i = 0; i < 4; ++i) v.push_back((x >> (8 * i)) & 0xFF); +} + +// One stored (method 0) zip member at offset 0, plus the matching ZipEntry. +struct Fixture { + MemorySource source; + ZipEntry entry; + ZipCatalog zip; // unused by BookXPath but part of its signature + + static Fixture fromXhtml(const std::string& xhtml) { + std::vector buf; + const char* name = "ch.xhtml"; + putU32(buf, 0x04034b50); // local file header signature + putU16(buf, 20); // version needed + putU16(buf, 0); // flags + putU16(buf, 0); // method: stored + putU16(buf, 0); // mod time + putU16(buf, 0); // mod date + putU32(buf, 0); // crc-32 (not validated on stored reads) + putU32(buf, static_cast(xhtml.size())); // compressed size + putU32(buf, static_cast(xhtml.size())); // uncompressed size + putU16(buf, static_cast(strlen(name))); // name length + putU16(buf, 0); // extra length + buf.insert(buf.end(), name, name + strlen(name)); + buf.insert(buf.end(), xhtml.begin(), xhtml.end()); + + Fixture f{MemorySource(std::move(buf)), ZipEntry{}, ZipCatalog{}}; + f.entry.name = "ch.xhtml"; + f.entry.method = 0; + f.entry.compressedSize = static_cast(xhtml.size()); + f.entry.uncompressedSize = static_cast(xhtml.size()); + f.entry.localHeaderOffset = 0; + return f; + } +}; + +// Character accounting reference for kChapter (mirrors ChapterLayout): +// p1 "Hello world" chars 0..10 +// p2 "Second para here" chars 11..26 +// p3 "Third" chars 27..31 +const char kChapter[] = + "" + "" + "Ignored Title" + "" + "

Hello world

Second para here

" + "

Third

" + ""; + +} // namespace + +TEST(BookXPath, SpineIndexFromXpath) { + EXPECT_EQ(BookXPath::spineIndexForXpath("/body/DocFragment[8]/body/p[4]/text().96"), 7); + EXPECT_EQ(BookXPath::spineIndexForXpath("/body/DocFragment[1]"), 0); + EXPECT_EQ(BookXPath::spineIndexForXpath("/body/nope"), -1); + EXPECT_EQ(BookXPath::spineIndexForXpath(""), -1); +} + +TEST(BookXPath, CharStartToXpathRealAncestry) { + auto f = Fixture::fromXhtml(kChapter); + + // Char 0: first char of p1 inside the div. + EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 0), + "/body/DocFragment[3]/body/div[1]/p[1]/text().0"); + + // Char 13 = "cond..." — 3rd char (offset 2) of p2. Whitespace in p1 + // ("Hello world") must have collapsed to a single space for this to hold. + EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 13), + "/body/DocFragment[3]/body/div[1]/p[2]/text().2"); + + // Char 28 = 2nd char of p3 — body's FIRST p child (div doesn't count: + // sibling indices are per-tag). + EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 28), + "/body/DocFragment[3]/body/p[1]/text().1"); + + // Past the end of the chapter: chapter-level fallback. + EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 2, 100000), "/body/DocFragment[3]"); +} + +TEST(BookXPath, XpathToCharStartRoundTrip) { + auto f = Fixture::fromXhtml(kChapter); + + uint32_t ch = 0; + ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, + "/body/DocFragment[3]/body/div[1]/p[2]/text().2", &ch)); + EXPECT_EQ(ch, 13u); + + ASSERT_TRUE( + BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]/body/p[1]/text().1", &ch)); + EXPECT_EQ(ch, 28u); + + // Elements without an explicit index default to [1]. + ASSERT_TRUE( + BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]/body/div/p[1]/text().5", &ch)); + EXPECT_EQ(ch, 5u); + + // Chapter-level xpath resolves to the chapter start. + ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[3]", &ch)); + EXPECT_EQ(ch, 0u); + + // Ancestry that does not exist in the chapter. + EXPECT_FALSE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, + "/body/DocFragment[3]/body/section[2]/p[9]/text().0", &ch)); +} + +TEST(BookXPath, InlineElementTargetsResolve) { + auto f = Fixture::fromXhtml(kChapter); + // KOReader may point inside an inline element: para starts at + // char 18 ("Second " = 7 chars into p2, which starts at 11). + uint32_t ch = 0; + ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, + "/body/DocFragment[3]/body/div[1]/p[2]/i[1]/text().0", &ch)); + EXPECT_EQ(ch, 18u); +} + +TEST(BookXPath, EntitiesAndBrAccounting) { + // "A&B" is 3 chars;
contributes one '\n' char (layout parity). + auto f = Fixture::fromXhtml( + "

A&B
C

"); + EXPECT_EQ(BookXPath::xpathForCharStart(f.source, f.zip, f.entry, 0, 4), + "/body/DocFragment[1]/body/p[1]/text().4"); // 'C' after A,&,B,\n + uint32_t ch = 0; + ASSERT_TRUE(BookXPath::charStartForXpath(f.source, f.zip, f.entry, "/body/DocFragment[1]/body/p[1]/text().4", &ch)); + EXPECT_EQ(ch, 4u); +} diff --git a/test/book_xpath/CMakeLists.txt b/test/book_xpath/CMakeLists.txt new file mode 100644 index 00000000..510b7e5d --- /dev/null +++ b/test/book_xpath/CMakeLists.txt @@ -0,0 +1,43 @@ +# BookXPath maps KOReader xpaths <-> FreeInkBook character offsets by +# streaming chapters through the engine's XmlSax; the suite builds the +# relevant engine sources host-side. Same FreeInkBook discovery as +# cpfont_adapter (submodule first, sibling working checkout fallback). +set(FREEINK_BOOK_DIR "${REPO_ROOT}/freeink-sdk/libs/book/FreeInkBook") +if(NOT EXISTS "${FREEINK_BOOK_DIR}/include") + set(FREEINK_BOOK_DIR "$ENV{HOME}/GitHub/freeink-sdk/libs/book/FreeInkBook") +endif() + +if(NOT EXISTS "${FREEINK_BOOK_DIR}/include") + message(WARNING "FreeInkBook sources not found - skipping BookXPathTest") +else() + enable_language(C) # vendored expat/miniz translation units + add_executable(BookXPathTest + BookXPathTest.cpp + ${REPO_ROOT}/lib/KOReaderSync/BookXPath.cpp + ${FREEINK_BOOK_DIR}/src/epub/XmlSax.cpp + ${FREEINK_BOOK_DIR}/src/epub/ZipCatalog.cpp + ${FREEINK_BOOK_DIR}/src/text/EntityFilter.cpp + ${FREEINK_BOOK_DIR}/src/vendor/expat_xmlparse.c + ${FREEINK_BOOK_DIR}/src/vendor/expat_xmlrole.c + ${FREEINK_BOOK_DIR}/src/vendor/expat_xmltok.c + ${FREEINK_BOOK_DIR}/src/vendor/miniz_impl.c + ) + + target_include_directories(BookXPathTest PRIVATE + ${REPO_ROOT}/lib/KOReaderSync + ${REPO_ROOT}/lib/Memory + ${FREEINK_BOOK_DIR}/include + ${FREEINK_BOOK_DIR}/third_party/expat + ${FREEINK_BOOK_DIR}/third_party/miniz + ) + + # The firmware stubs (Logging/Memory) come from a tiny local shim dir. + target_include_directories(BookXPathTest BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/shim) + + target_link_libraries(BookXPathTest PRIVATE + crosspoint_test_common + GTest::gtest_main + ) + + gtest_discover_tests(BookXPathTest) +endif() diff --git a/test/book_xpath/shim/Logging.h b/test/book_xpath/shim/Logging.h new file mode 100644 index 00000000..f408139b --- /dev/null +++ b/test/book_xpath/shim/Logging.h @@ -0,0 +1,6 @@ +#pragma once +// Host-test shim for the firmware Logging.h (which drags in Arduino Serial). +#include +#define LOG_DBG(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n")) +#define LOG_INF(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n")) +#define LOG_ERR(tag, ...) (std::printf("[%s] ", tag), std::printf(__VA_ARGS__), std::printf("\n"))