From 3421171670ba3efc7daa7f2353d06ca5791cde3f Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 8 Jul 2026 00:56:53 -0400 Subject: [PATCH] Add character-offset bookmarks for FreeInkBook Implements layout-independent bookmarks using character offsets within chapters instead of percentage-based positions. This allows bookmarks to restore to the exact position regardless of font size or orientation changes. Adds TOC navigation helpers, book progress calculation based on spine item sizes, and a NONE BidiBaseDir option for pre-reordered text. Legacy bookmarks without character offsets fall back to percentage-based positioning. --- lib/EpdFont/EpdFontFamily.cpp | 4 +- lib/GfxRenderer/GfxRenderer.cpp | 1 + lib/GfxRenderer/GfxRenderer.h | 5 +- src/BookmarkEntry.h | 7 + src/JsonSettingsIO.cpp | 3 + src/activities/ActivityResult.h | 3 + src/activities/reader/BookPaginator.cpp | 87 +- src/activities/reader/BookPaginator.h | 27 +- src/activities/reader/CpFontAdapter.cpp | 3 +- src/activities/reader/EpubReaderActivity.cpp | 1310 +++++------------ src/activities/reader/EpubReaderActivity.h | 95 +- .../reader/EpubReaderBookmarksActivity.cpp | 18 +- .../reader/EpubReaderBookmarksActivity.h | 13 +- .../EpubReaderChapterSelectionActivity.cpp | 17 +- .../EpubReaderChapterSelectionActivity.h | 18 +- src/activities/reader/EpubReaderUtils.h | 80 +- src/activities/reader/FreeInkBookStorage.h | 6 +- src/activities/reader/FreeInkPageRenderer.cpp | 239 +++ src/activities/reader/FreeInkPageRenderer.h | 45 + .../reader/KOReaderSyncActivity.cpp | 12 +- src/activities/reader/ReaderActivity.cpp | 84 +- src/activities/reader/ReaderActivity.h | 9 +- src/activities/reader/TxtReaderActivity.cpp | 573 ++----- src/activities/reader/TxtReaderActivity.h | 55 +- 24 files changed, 1090 insertions(+), 1624 deletions(-) create mode 100644 src/activities/reader/FreeInkPageRenderer.cpp create mode 100644 src/activities/reader/FreeInkPageRenderer.h diff --git a/lib/EpdFont/EpdFontFamily.cpp b/lib/EpdFont/EpdFontFamily.cpp index 8b7c4ba3..ba0421e6 100644 --- a/lib/EpdFont/EpdFontFamily.cpp +++ b/lib/EpdFont/EpdFontFamily.cpp @@ -28,9 +28,7 @@ const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) co return getFont(style)->getGlyph(cp); } -bool EpdFontFamily::hasGlyph(const uint32_t cp, const Style style) const { - return getFont(style)->hasGlyph(cp); -} +bool EpdFontFamily::hasGlyph(const uint32_t cp, const Style style) const { return getFont(style)->hasGlyph(cp); } int8_t EpdFontFamily::getKerning(const uint32_t leftCp, const uint32_t rightCp, const Style style) const { return getFont(style)->getKerning(leftCp, rightCp); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1e9ad982..a7e1a56a 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -471,6 +471,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha namespace { const char* resolveVisualText(const char* text, std::string& visualBuffer, const BidiUtils::BidiBaseDir baseDir) { if (!text || *text == '\0') return text; + if (baseDir == BidiUtils::BidiBaseDir::NONE) return text; // caller supplies visual order if (baseDir != BidiUtils::BidiBaseDir::RTL) { // Byte-level scan: skip BiDi when no RTL script lead bytes are present. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 5aa9ae72..04892f2e 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -8,7 +8,10 @@ namespace BidiUtils { // AUTO: scan text for first strong directional character (P2/P3 rules) // LTR: force left-to-right paragraph embedding level // RTL: force right-to-left paragraph embedding level -enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; +// NONE: text is already in visual order (e.g. FreeInkBook page runs, which +// bake UAX#9 reordering and Arabic shaping in at layout time) — draw +// byte-for-byte, never reorder +enum class BidiBaseDir : signed char { NONE = -2, AUTO = -1, LTR = 0, RTL = 1 }; } // namespace BidiUtils class FontCacheManager; diff --git a/src/BookmarkEntry.h b/src/BookmarkEntry.h index 954c24f3..322de729 100644 --- a/src/BookmarkEntry.h +++ b/src/BookmarkEntry.h @@ -11,4 +11,11 @@ struct BookmarkEntry { uint16_t computedSpineIndex = 0; // Spine index at the time of bookmarking uint16_t computedChapterPageCount = 0; // Total page count of the chapter at the time of bookmarking uint16_t computedChapterProgress = 0; // Number of pages into the chapter at the time of bookmarking + + // FreeInkBook locator: chapter character offset of the bookmarked page. + // Layout-parameter independent, so it restores exactly at any font size or + // orientation. Entries written before the engine swap lack it (hasCharStart + // false) and fall back to the percentage fields above. + uint32_t charStart = 0; + bool hasCharStart = false; }; \ No newline at end of file diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 92139b69..a4e6b7cd 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -424,6 +424,7 @@ bool JsonSettingsIO::saveBookmarks(const std::vector& bookmarks, obj["si"] = bookmark.computedSpineIndex; obj["pc"] = bookmark.computedChapterPageCount; obj["pp"] = bookmark.computedChapterProgress; + if (bookmark.hasCharStart) obj["cs"] = bookmark.charStart; } String json; @@ -451,6 +452,8 @@ bool JsonSettingsIO::loadBookmarks(std::vector& bookmarks, const bookmark.computedSpineIndex = obj["si"] | static_cast(0); bookmark.computedChapterPageCount = obj["pc"] | static_cast(0); bookmark.computedChapterProgress = obj["pp"] | static_cast(0); + bookmark.hasCharStart = !obj["cs"].isNull(); + bookmark.charStart = obj["cs"] | static_cast(0); } LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size()); diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 31a95398..fa286964 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -47,6 +47,9 @@ struct ProgressChangeResult { std::string xpath; float percentage = 0.0f; bool hasSavedProgress = false; + // FreeInkBook locator (chapter character offset); exact when present. + uint32_t charStart = 0; + bool hasCharStart = false; }; enum class NetworkMode; diff --git a/src/activities/reader/BookPaginator.cpp b/src/activities/reader/BookPaginator.cpp index cb40b521..3fe6930a 100644 --- a/src/activities/reader/BookPaginator.cpp +++ b/src/activities/reader/BookPaginator.cpp @@ -53,7 +53,8 @@ class ProgressSink : public freeink::book::PageSink { } // namespace -bool BookPaginator::open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer) { +bool BookPaginator::open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer, + const bool forcePlainText) { close(); bookBuf_ = makeUniqueNoThrow(kBookArenaSize); @@ -78,7 +79,7 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G } const size_t len = path.size(); - isTxt_ = len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0; + isTxt_ = forcePlainText || (len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0); if (!isTxt_) { // Container open + book stylesheet need parse scratch; both are @@ -269,9 +270,7 @@ uint32_t BookPaginator::fontFingerprint() const { return hash; } -uint32_t BookPaginator::generation() const { - return freeink::book::layoutGenerationHash(params_, fontFingerprint()); -} +uint32_t BookPaginator::generation() const { return freeink::book::layoutGenerationHash(params_, fontFingerprint()); } freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex, const BuildProgress& progress) { const uint32_t gen = generation(); @@ -348,6 +347,84 @@ int BookPaginator::spineIndexForHref(const char* href) const { return -1; } +BookPaginator::TocItem BookPaginator::tocItem(const size_t index) const { + TocItem out{"", nullptr, -1, 0}; + const freeink::book::TocEntry* entry = isTxt_ ? nullptr : book_.tocEntry(index); + if (entry == nullptr) return out; + out.title = entry->title; + out.fragment = entry->fragment; + out.depth = entry->depth; + out.spineIndex = spineIndexForHref(entry->href); + return out; +} + +int BookPaginator::tocIndexForSpine(const int spineIndex) const { + // The chapter's title is the last TOC entry at or before this spine item + // (a spine item without its own entry belongs to the preceding heading). + int best = -1; + int bestSpine = -1; + for (size_t t = 0; t < tocCount(); ++t) { + const int s = tocItem(t).spineIndex; + if (s < 0 || s > spineIndex) continue; + if (s >= bestSpine) { + bestSpine = s; + best = static_cast(t); + } + } + return best; +} + +// Spine weights use the uncompressed sizes already in the ZIP catalog — the +// same "bigger chapters cover more of the book" heuristic the legacy engine +// used, with zero extra state. +float BookPaginator::bookProgress(const int spineIndex, const float chapterFraction) const { + if (isTxt_) return chapterFraction; + uint64_t before = 0; + uint64_t current = 0; + uint64_t total = 0; + for (size_t s = 0; s < book_.spineCount(); ++s) { + const ManifestItem* item = book_.spineItem(s); + const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr; + const uint32_t size = e != nullptr ? e->uncompressedSize : 0; + if (static_cast(s) < spineIndex) before += size; + if (static_cast(s) == spineIndex) current = size; + total += size; + } + if (total == 0) return 0.0f; + const float f = chapterFraction < 0.0f ? 0.0f : (chapterFraction > 1.0f ? 1.0f : chapterFraction); + return (static_cast(before) + f * static_cast(current)) / static_cast(total); +} + +int BookPaginator::spineForBookFraction(const float bookFraction, float* chapterFractionOut) const { + if (chapterFractionOut != nullptr) *chapterFractionOut = 0.0f; + if (isTxt_ || book_.spineCount() == 0) { + if (chapterFractionOut != nullptr) *chapterFractionOut = bookFraction; + return 0; + } + uint64_t total = 0; + for (size_t s = 0; s < book_.spineCount(); ++s) { + const ManifestItem* item = book_.spineItem(s); + const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr; + total += e != nullptr ? e->uncompressedSize : 0; + } + const float f = bookFraction < 0.0f ? 0.0f : (bookFraction > 1.0f ? 1.0f : bookFraction); + const uint64_t target = static_cast(f * static_cast(total)); + uint64_t cumulative = 0; + for (size_t s = 0; s < book_.spineCount(); ++s) { + const ManifestItem* item = book_.spineItem(s); + const freeink::book::ZipEntry* e = item != nullptr ? book_.zip().find(item->href) : nullptr; + const uint32_t size = e != nullptr ? e->uncompressedSize : 0; + if (target < cumulative + size || s + 1 == book_.spineCount()) { + if (chapterFractionOut != nullptr && size > 0) { + *chapterFractionOut = static_cast(target - cumulative) / static_cast(size); + } + return static_cast(s); + } + cumulative += size; + } + return 0; +} + int BookPaginator::fontIdForRunSize(const uint16_t sizePx) const { const uint16_t q = adapters_[0].quantize(sizePx); for (uint8_t i = 0; i < ladderCount_; ++i) { diff --git a/src/activities/reader/BookPaginator.h b/src/activities/reader/BookPaginator.h index b75b4581..20497b4b 100644 --- a/src/activities/reader/BookPaginator.h +++ b/src/activities/reader/BookPaginator.h @@ -47,15 +47,38 @@ class BookPaginator { // Opens the container and builds the font chain. `cacheDir` is the // per-book directory (".crosspoint/epub_"). Plain-text files open - // as a one-chapter book with no container. - bool open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer); + // as a one-chapter book with no container — detected by .txt extension, or + // forced via `forcePlainText` (markdown files read as plain text). + bool open(const std::string& path, const std::string& cacheDir, GfxRenderer& renderer, bool forcePlainText = false); void close(); bool isOpen() const { return open_; } bool isTxt() const { return isTxt_; } freeink::book::Book& book() { return book_; } + freeink::book::BookSource* bookSource() { return &source_; } size_t spineCount() const { return isTxt_ ? 1 : book_.spineCount(); } const char* language() const; + const char* title() const { return isTxt_ ? "" : book_.metadata().title; } + const char* author() const { return isTxt_ ? "" : book_.metadata().author; } + + // --- TOC (flattened, resolved to spine indices) -------------------------- + struct TocItem { + const char* title; + const char* fragment; // anchor within the chapter, or nullptr + int spineIndex; // -1 when the href is not a spine item + uint8_t depth; + }; + size_t tocCount() const { return isTxt_ ? 0 : book_.tocCount(); } + TocItem tocItem(size_t index) const; + // First TOC entry pointing at `spineIndex` or an earlier chapter (the + // chapter's display title); -1 when the TOC has no such entry. + int tocIndexForSpine(int spineIndex) const; + + // --- whole-book progress (uncompressed spine byte weights) --------------- + // Fraction of the book at (spine, fraction-within-chapter), 0..1. + float bookProgress(int spineIndex, float chapterFraction) const; + // Inverse: which spine (and where inside it) a whole-book fraction lands. + int spineForBookFraction(float bookFraction, float* chapterFractionOut) const; // Refreshes LayoutParams from SETTINGS and the given content box. Must be // called before ensureChapter() and after any settings/orientation change; diff --git a/src/activities/reader/CpFontAdapter.cpp b/src/activities/reader/CpFontAdapter.cpp index 617cf94e..75bb0ef8 100644 --- a/src/activities/reader/CpFontAdapter.cpp +++ b/src/activities/reader/CpFontAdapter.cpp @@ -51,8 +51,7 @@ int16_t CpFontAdapter::ascent(const uint16_t sizePx) { return family != nullptr ? static_cast(family->getData(style_)->ascender) : 0; } -int16_t CpFontAdapter::kerning(const uint32_t left, const uint32_t right, const uint16_t sizePx, - uint8_t) { +int16_t CpFontAdapter::kerning(const uint32_t left, const uint32_t right, const uint16_t sizePx, uint8_t) { if (utf8IsCombiningMark(left) || utf8IsCombiningMark(right)) return 0; const EpdFontFamily* family = familyFor(sizePx); if (family == nullptr) return 0; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7256c44b..e5929cf8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,7 +1,5 @@ #include "EpubReaderActivity.h" -#include -#include #include #include #include @@ -13,11 +11,8 @@ #include #include -#include -#include #include -#include "BookmarkEntry.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderBookmarksActivity.h" @@ -25,11 +20,10 @@ #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" -#include "FreeInkBookStorage.h" +#include "FreeInkPageRenderer.h" #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "MappedInputManager.h" -#include "ProgressMapper.h" #include "QrDisplayActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" @@ -39,11 +33,9 @@ #include "util/ScreenshotUtil.h" namespace { -// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pages per minute, first item is 1 to prevent division by zero if accessed constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12}; constexpr size_t initialBookmarkCacheCapacity = 16; -constexpr float bookmarkProgressEpsilon = 0.0001f; int clampPercent(int percent) { if (percent < 0) { @@ -56,48 +48,27 @@ int clampPercent(int percent) { } // SD card folder finished books are moved into. Single source of truth for the path. -// constexpr ⇒ lives in flash .rodata, no DRAM cost. constexpr char READ_FOLDER[] = "/read"; -// True if path is inside READ_FOLDER (starts with "/"). Non-allocating so -// it is cheap to call from loop(), and avoids reintroducing a separate "/Read/" literal. bool isInReadFolder(const std::string& path) { - constexpr size_t n = sizeof(READ_FOLDER) - 1; // length of "/Read" (excludes NUL) + constexpr size_t n = sizeof(READ_FOLDER) - 1; return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/'; } -struct ProgressRange { - float start; - float end; -}; - -ProgressRange getPageProgressRange(const std::shared_ptr& epub, const int spineIndex, const int page, - const int pageCount) { - if (pageCount <= 1) { - return {epub->calculateProgress(spineIndex, 0.0f), epub->calculateProgress(spineIndex, 1.0f)}; - } - - const float step = 1.0f / static_cast(pageCount - 1); - const float anchor = std::clamp(static_cast(page) * step, 0.0f, 1.0f); - const float start = std::max(0.0f, anchor - (step * 0.5f)); - const float end = std::min(1.0f, anchor + (step * 0.5f)); - return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)}; +std::string cacheDirForBook(const std::string& path) { + return "/.crosspoint/epub_" + std::to_string(std::hash{}(path)); } -bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount, - const ProgressRange& pageRange) { - if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount && - bookmark.computedChapterProgress == page) { - return true; - } - - const float bookmarkProgress = std::clamp(bookmark.percentage, 0.0f, 1.0f); - return bookmarkProgress + bookmarkProgressEpsilon >= pageRange.start && - bookmarkProgress - bookmarkProgressEpsilon <= pageRange.end; +// Display fallback when the OPF carries no title: the bare filename. +std::string filenameTitle(const std::string& path) { + const size_t slash = path.rfind('/'); + const size_t start = slash == std::string::npos ? 0 : slash + 1; + const size_t dot = path.rfind('.'); + const size_t end = (dot == std::string::npos || dot <= start) ? path.size() : dot; + return path.substr(start, end - start); } // Pick a non-colliding destination path inside /Read/ for a finished book. -// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc. std::string buildReadFolderDestination(const std::string& srcPath) { const size_t lastSlash = srcPath.rfind('/'); const std::string filename = (lastSlash != std::string::npos) ? srcPath.substr(lastSlash + 1) : srcPath; @@ -121,7 +92,6 @@ std::string buildReadFolderDestination(const std::string& srcPath) { // Relocate a finished book and its cache dir into /read/, keep it in recents by // repointing its entry to the new path, and repoint the resume pointer too. -// On rename failure: LOG_ERR and leave everything in place (no UI alert subsystem here). void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& dstPath, const std::string& oldCachePath) { LOG_INF("ERS", "Moving finished epub: %s -> %s", srcPath.c_str(), dstPath.c_str()); @@ -130,16 +100,14 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& return; } - // Cache dir is keyed by hash of the epub path (see Epub ctor), so it must be re-keyed. - const std::string newCachePath = "/.crosspoint/epub_" + std::to_string(std::hash{}(dstPath)); + // Cache dir is keyed by hash of the epub path, so it must be re-keyed. + const std::string newCachePath = cacheDirForBook(dstPath); if (!oldCachePath.empty() && Storage.exists(oldCachePath.c_str())) { if (!Storage.rename(oldCachePath.c_str(), newCachePath.c_str())) { LOG_ERR("ERS", "Failed to rename cache dir %s -> %s (non-fatal)", oldCachePath.c_str(), newCachePath.c_str()); } } - // Keep the book in recents (crossink behavior): repoint the entry to its new - // location instead of dropping it. updatePath persists on success. RECENT_BOOKS.updatePath(srcPath, dstPath, oldCachePath, newCachePath); if (APP_STATE.openEpubPath == srcPath) { APP_STATE.openEpubPath = dstPath; @@ -147,60 +115,50 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& } } +// Synthetic KOReader-style xpath for a chapter (1-based DocFragment index). +// Percentage is the primary sync mechanism; the fragment index carries the +// chapter for display and same-book sanity checks. +std::string syntheticXPath(const int spineIndex) { + return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body/p[1]/text().0"; +} + } // namespace void EpubReaderActivity::onEnter() { Activity::onEnter(); - if (!epub) { - return; - } - - // Configure screen orientation based on settings // NOTE: This affects layout math and must be applied before any render calls. ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - epub->setupCacheDir(); + cacheDir_ = cacheDirForBook(path_); + Storage.ensureDirectoryExists("/.crosspoint"); - HalFile f; - if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { - uint8_t data[6]; - int dataSize = f.read(data, 6); - 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); - } - if (dataSize == 6) { - cachedChapterTotalPageCount = data[4] + (data[5] << 8); - } + if (!paginator.open(path_, cacheDir_, renderer)) { + LOG_ERR("ERS", "Failed to open book: %s", path_.c_str()); + activityManager.goToFullScreenMessage(tr(STR_PAGE_LOAD_ERROR), EpdFontFamily::BOLD); + return; } - // We may want a better condition to detect if we are opening for the first time. - // This will trigger if the book is re-opened at Chapter 0. - if (currentSpineIndex == 0) { - int textSpineIndex = epub->getSpineIndexForTextReference(); - if (textSpineIndex != 0) { - currentSpineIndex = textSpineIndex; - LOG_DBG("ERS", "Opened for first time, navigating to text reference at index %d", textSpineIndex); + + const auto progress = EpubReaderUtils::loadProgress(cacheDir_); + if (progress.valid) { + currentSpineIndex = progress.spineIndex; + if (progress.charStart != EpubReaderUtils::kNoCharStart) { + pendingCharStart = progress.charStart; + } else { + pendingChapterFraction = static_cast(progress.fractionQ16) / 65536.0f; } } - // Save current epub as last opened epub and add to recent books - APP_STATE.openEpubPath = epub->getPath(); + // Save current epub as last opened epub and add to recent books. + APP_STATE.openEpubPath = path_; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath()); + std::string title = paginator.title(); + if (title.empty()) { + title = filenameTitle(path_); + } + RECENT_BOOKS.addBook(path_, title, paginator.author(), cacheDir_ + "/thumb_[HEIGHT].bmp"); loadCachedBookmarks(); - - // Trigger first update requestUpdate(); } @@ -215,106 +173,73 @@ void EpubReaderActivity::onExit() { // Leaving mid-footnote loses the in-RAM return stack on deep sleep; persist the // pre-footnote position so the book reopens at the link origin, not the footnote. - if (footnoteDepth > 0 && epub) { + if (footnoteDepth > 0) { const SavedPosition& origin = savedPositions[0]; - saveProgress(origin.spineIndex, origin.pageNumber, 0); + EpubReaderUtils::saveProgress(cacheDir_, static_cast(origin.spineIndex), origin.charStart); } - section.reset(); - if (pendingReadFolderMove && epub) { - const std::string srcPath = epub->getPath(); - const std::string oldCachePath = epub->getCachePath(); - const std::string dstPath = buildReadFolderDestination(srcPath); - epub.reset(); // release the Epub (and any open handles) before renaming on the SD card - moveFinishedBookToReadFolder(srcPath, dstPath, oldCachePath); - } else { - epub.reset(); + paginator.close(); + if (pendingReadFolderMove) { + const std::string dstPath = buildReadFolderDestination(path_); + moveFinishedBookToReadFolder(path_, dstPath, cacheDir_); } } +float EpubReaderActivity::currentBookFraction() const { + if (!paginator.chapterReady() || paginator.totalChars() == 0) { + return paginator.bookProgress(currentSpineIndex, 0.0f); + } + const float chapterFraction = static_cast(lastCharStart) / static_cast(paginator.totalChars()); + return paginator.bookProgress(currentSpineIndex, chapterFraction); +} + void EpubReaderActivity::openReaderMenu() { - const int currentPage = section ? section->currentPage + 1 : 0; - const int totalPages = section ? section->estimatedTotalPages() : 0; - float bookProgress = 0.0f; - if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { - const float chapterProgress = - static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); - bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; - } - const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()), - [this](const ActivityResult& result) { - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + const int currentPageDisplay = paginator.chapterReady() ? static_cast(currentPage) + 1 : 0; + const int totalPages = paginator.chapterReady() ? static_cast(paginator.pageCount()) : 0; + const int bookProgressPercent = clampPercent(static_cast(currentBookFraction() * 100.0f + 0.5f)); + startActivityForResult( + std::make_unique(renderer, mappedInput, paginator.title(), currentPageDisplay, totalPages, + bookProgressPercent, SETTINGS.orientation, !currentPageFootnotes.empty(), + !cachedBookmarks.empty()), + [this](const ActivityResult& result) { + // Always apply orientation change even if the menu was cancelled + const auto& menu = std::get(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(menu.action)); + } + }); } void EpubReaderActivity::loop() { - if (!epub) { - // Should never happen + if (!paginator.isOpen()) { finish(); return; } - // Drive any in-progress incremental section build forward, off the page-turn critical path, - // but only within a small window ahead of the reader: an unbounded build monopolized the - // RenderLock and locked out page turns. The build follows the reader instead, and instant - // reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit. - // Skip while the render mutex is busy so we never delay a pending render; re-check - // isBuilding() under the lock since render() may have just finished it. - if (section && section->isBuilding() && !RenderLock::peek() && - static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) { - RenderLock lock; - // Re-check under the lock: render() (which also holds the RenderLock) may have finalized the - // build between the outer isBuilding() check and acquiring the lock here, in which case - // buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task - // mutation, so it flags this as always true. - // cppcheck-suppress knownConditionTrueFalse - if (section->isBuilding()) { - if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { - LOG_ERR("ERS", "Background section build failed"); - section.reset(); - requestUpdate(); - } else if (section->isBuildComplete() && applyDeferredReposition()) { - // The chapter re-paginated since the saved progress (settings changed): we now know the - // real page count, so re-render at the remapped page. No-op for an unchanged resume. - requestUpdate(); - } - } - } + const int spineCount = static_cast(paginator.spineCount()); + const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= spineCount; - // End-of-Book screen reached (currentSpineIndex == spine count) means the book is - // finished. Two independent finished-book features key off this same condition. - const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount(); - - // Drop this book from the Recent Books list; if the reader then pages back into the book, - // re-add it. So removal only sticks if the reader leaves while still on the End-of-Book - // screen. Acts only on the transition (guarded by recentsEntryRemoved) — no per-frame writes. + // Drop this book from the Recent Books list at End-of-Book; re-add if the + // reader pages back in. Acts only on transitions — no per-frame writes. if (SETTINGS.removeReadBooksFromRecents) { if (atEndOfBook && !recentsEntryRemoved) { - // Only treat the book as "removed by us" if it was actually in the list, so the - // re-add branch below doesn't insert a book the feature never removed. - recentsEntryRemoved = RECENT_BOOKS.removeByPath(epub->getPath()); + recentsEntryRemoved = RECENT_BOOKS.removeByPath(path_); } else if (!atEndOfBook && recentsEntryRemoved) { - // Re-add (goes to front of the list via addBook — accepted ordering side effect). - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath()); + std::string title = paginator.title(); + if (title.empty()) { + title = filenameTitle(path_); + } + RECENT_BOOKS.addBook(path_, title, paginator.author(), cacheDir_ + "/thumb_[HEIGHT].bmp"); recentsEntryRemoved = false; } } - // Arm the move here so ANY exit path (Back, Home, file browser) relocates the book into - // /Read/ in onExit(); paging back off the end screen disarms it (book not actually - // finished). If removeReadBooksFromRecents also fired, RecentBooksStore::updatePath in the - // move path becomes a safe no-op since the entry was already removed. + // Arm the move so ANY exit path relocates the finished book into /Read/; + // paging back off the end screen disarms it. if (atEndOfBook) { - pendingReadFolderMove = SETTINGS.moveFinishedToReadFolder && !isInReadFolder(epub->getPath()); + pendingReadFolderMove = SETTINGS.moveFinishedToReadFolder && !isInReadFolder(path_); } else { pendingReadFolderMove = false; } @@ -323,13 +248,7 @@ void EpubReaderActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back)) { automaticPageTurnActive = false; - // updates chapter title space to indicate page turn disabled - requestUpdate(); - return; - } - - if (!section) { - requestUpdate(); + requestUpdate(); // updates chapter title space to indicate page turn disabled return; } @@ -350,11 +269,7 @@ void EpubReaderActivity::loop() { requestUpdate(); } - // While the end screen suggestion menu is showing it owns Confirm/Back/navigation - // input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls - // through to the regular handlers below; page turns are absorbed by the end-of-book - // block. A Confirm release after a long-press function (bookmark/sync) fired is left - // to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease. + // While the end screen suggestion menu is showing it owns Confirm/Back/navigation input. if (atEndOfBook && endOfBookOptions.menuActive() && !(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) { std::string openPath; @@ -366,9 +281,8 @@ void EpubReaderActivity::loop() { onGoHome(); return; case EndOfBookOptions::Action::LastPage: - currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0); - nextPageNumber = 0; - pendingPageJump = std::numeric_limits::max(); + currentSpineIndex = std::max(spineCount - 1, 0); + pendingLastPage = true; requestUpdate(); return; case EndOfBookOptions::Action::Redraw: @@ -379,9 +293,7 @@ void EpubReaderActivity::loop() { } } - // Enter reader menu activity on short-press Confirm. A long-press that fired a bound - // function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release - // following the hold does not also open the menu. + // Enter reader menu activity on short-press Confirm. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; @@ -394,7 +306,6 @@ void EpubReaderActivity::loop() { if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) { switch (SETTINGS.longPressMenuFunction) { case CrossPointSettings::LP_MENU_BOOKMARK: - // Hold ~0.4s drops a bookmark at the current page. if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showBookmarkMessage) { addBookmark(); showBookmarkMessage = true; @@ -404,11 +315,9 @@ void EpubReaderActivity::loop() { } break; case CrossPointSettings::LP_MENU_KOSYNC: - // Hold ~1s launches KOReader sync. If sync can't run (no credentials stored), fall - // through so the normal Confirm-release still opens the reader menu. if (mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { if (launchKOReaderSync()) { - ignoreNextConfirmRelease = true; // sync launched or error shown; suppress menu open + ignoreNextConfirmRelease = true; return; } } @@ -421,7 +330,7 @@ void EpubReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - activityManager.goToFileBrowser(epub ? epub->getPath() : ""); + activityManager.goToFileBrowser(path_); return; } @@ -436,8 +345,6 @@ void EpubReaderActivity::loop() { return; } - // auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput); - // Handle short power button press for footnotes if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::FOOTNOTES && mappedInput.wasReleased(MappedInputManager::Button::Power) && @@ -467,20 +374,16 @@ void EpubReaderActivity::loop() { return; } - // At end of the book with no suggestion menu, forward button goes home and back - // button returns to last page - if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { + // At end of the book with no suggestion menu, forward goes home; back returns to last page. + if (atEndOfBook) { if (endOfBookOptions.menuActive()) { - // Selection movement was handled above; absorb leftover page-turn triggers so - // e.g. "previous" at the top of the list doesn't jump back into the book - return; + return; // selection movement handled above; absorb leftover page-turn triggers } if (nextTriggered) { onGoHome(); } else { - currentSpineIndex = epub->getSpineItemsCount() - 1; - nextPageNumber = 0; - pendingPageJump = std::numeric_limits::max(); + currentSpineIndex = spineCount - 1; + pendingLastPage = true; requestUpdate(); } return; @@ -494,23 +397,17 @@ void EpubReaderActivity::loop() { } if (longPress && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP) { - if (!nextTriggered && section && section->currentPage > 0) { - section->currentPage = 0; + if (!nextTriggered && currentPage > 0) { + currentPage = 0; requestUpdate(); return; } - - // We don't want to delete the section mid-render, so grab the semaphore - { - RenderLock lock(*this); - nextPageNumber = 0; - if (nextTriggered) { - currentSpineIndex++; - } else if (currentSpineIndex > 0) { - currentSpineIndex--; - } - section.reset(); + if (nextTriggered) { + currentSpineIndex++; + } else if (currentSpineIndex > 0) { + currentSpineIndex--; } + currentPage = 0; requestUpdate(); return; } @@ -524,80 +421,17 @@ void EpubReaderActivity::loop() { return; } - // No current section, attempt to rerender the book - if (!section) { - requestUpdate(); - return; - } - - if (prevTriggered) { - pageTurn(false); - } else { - pageTurn(true); - } + pageTurn(nextTriggered); } -// Translate an absolute percent into a spine index plus a normalized position -// within that spine so we can jump after the section is loaded. void EpubReaderActivity::jumpToPercent(int percent) { - if (!epub) { - return; - } - - const size_t bookSize = epub->getBookSize(); - if (bookSize == 0) { - return; - } - - // Normalize input to 0-100 to avoid invalid jumps. percent = clampPercent(percent); - - // Convert percent into a byte-like absolute position across the spine sizes. - // Use an overflow-safe computation: (bookSize / 100) * percent + (bookSize % 100) * percent / 100 - size_t targetSize = - (bookSize / 100) * static_cast(percent) + (bookSize % 100) * static_cast(percent) / 100; - if (percent >= 100) { - // Ensure the final percent lands inside the last spine item. - targetSize = bookSize - 1; - } - - const int spineCount = epub->getSpineItemsCount(); - if (spineCount == 0) { - return; - } - - int targetSpineIndex = spineCount - 1; - size_t prevCumulative = 0; - - for (int i = 0; i < spineCount; i++) { - const size_t cumulative = epub->getCumulativeSpineItemSize(i); - if (targetSize <= cumulative) { - // Found the spine item containing the absolute position. - targetSpineIndex = i; - prevCumulative = (i > 0) ? epub->getCumulativeSpineItemSize(i - 1) : 0; - break; - } - } - - const size_t cumulative = epub->getCumulativeSpineItemSize(targetSpineIndex); - const size_t spineSize = (cumulative > prevCumulative) ? (cumulative - prevCumulative) : 0; - // Store a normalized position within the spine so it can be applied once loaded. - pendingSpineProgress = - (spineSize == 0) ? 0.0f : static_cast(targetSize - prevCumulative) / static_cast(spineSize); - if (pendingSpineProgress < 0.0f) { - pendingSpineProgress = 0.0f; - } else if (pendingSpineProgress > 1.0f) { - pendingSpineProgress = 1.0f; - } - - // Reset state so render() reloads and repositions on the target spine. - { - RenderLock lock(*this); - currentSpineIndex = targetSpineIndex; - nextPageNumber = 0; - pendingPercentJump = true; - section.reset(); - } + float chapterFraction = 0.0f; + const int spine = paginator.spineForBookFraction(static_cast(percent) / 100.0f, &chapterFraction); + currentSpineIndex = spine; + pendingCharStart.reset(); + pendingChapterFraction = chapterFraction; + requestUpdate(); } void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { @@ -605,56 +439,35 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction loadCachedBookmarks(); if (!result.isCancelled) { const auto& sync = std::get(result.data); - int targetSpineIndex = sync.spineIndex; - int targetPage = sync.page; - const int activeTotalPages = section ? section->estimatedTotalPages() : 0; - const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 && - currentSpineIndex == sync.spineIndex && sync.page >= 0 && - sync.page < sync.totalPages && activeTotalPages == sync.totalPages; - - if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) { - const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; - CrossPointPosition fallback = - ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages); - targetSpineIndex = fallback.spineIndex; - targetPage = fallback.pageNumber; - } - - if (currentSpineIndex != targetSpineIndex) { - RenderLock lock(*this); - currentSpineIndex = targetSpineIndex; - nextPageNumber = targetPage; - section.reset(); - } else if (section && section->currentPage != targetPage) { - RenderLock lock(*this); - const int clampedTargetPage = std::max(0, targetPage); - section->currentPage = clampedTargetPage; - } else if (!section) { - nextPageNumber = targetPage; + currentSpineIndex = sync.spineIndex; + pendingChapterFraction.reset(); + pendingCharStart.reset(); + if (sync.hasCharStart) { + pendingCharStart = sync.charStart; // exact locator + } else if (sync.hasSavedProgress) { + // Legacy bookmark: percentage is whole-book; land inside its chapter. + float chapterFraction = 0.0f; + currentSpineIndex = paginator.spineForBookFraction(sync.percentage, &chapterFraction); + pendingChapterFraction = chapterFraction; + } else if (sync.totalPages > 0) { + pendingChapterFraction = static_cast(sync.page) / static_cast(sync.totalPages); } + requestUpdate(); } }; switch (action) { case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { - const int spineIdx = currentSpineIndex; - const std::string path = epub->getPath(); startActivityForResult( - std::make_unique(renderer, mappedInput, epub, path, spineIdx), + std::make_unique(renderer, mappedInput, paginator, currentSpineIndex), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& chapterResult = std::get(result.data); - RenderLock lock(*this); - currentSpineIndex = chapterResult.spineIndex; - - // If anchor is not empty, it will be used later to calculate the page number. pendingAnchor = chapterResult.anchor; - - // Otherwise page 0 will be used. - nextPageNumber = 0; - - section.reset(); + pendingCharStart.reset(); + pendingChapterFraction.reset(); + currentPage = 0; } }); break; @@ -671,12 +484,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: { - float bookProgress = 0.0f; - if (epub && epub->getBookSize() > 0 && section && section->pageCount > 0) { - const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); - bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; - } - const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); + const int initialPercent = clampPercent(static_cast(currentBookFraction() * 100.0f + 0.5f)); startActivityForResult( std::make_unique(renderer, mappedInput, initialPercent), [this](const ActivityResult& result) { @@ -687,16 +495,13 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: { - if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { - std::string fullText = section->getTextFromSectionFile(); - if (!fullText.empty()) { - startActivityForResult(std::make_unique(renderer, mappedInput, fullText), - [this](const ActivityResult& result) {}); - break; - } + std::string fullText = currentPageText(); + if (!fullText.empty()) { + startActivityForResult(std::make_unique(renderer, mappedInput, fullText), + [](const ActivityResult&) {}); + } else { + requestUpdate(); } - // If no text or page loading failed, just close menu - requestUpdate(); break; } case EpubReaderMenuActivity::MenuAction::GO_HOME: { @@ -706,17 +511,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: { { RenderLock lock(*this); - if (epub && section) { - uint16_t backupSpine = currentSpineIndex; - uint16_t backupPage = section->currentPage; - uint16_t backupPageCount = section->pageCount; - section.reset(); - epub->clearCache(); - epub->setupCacheDir(); - if (!saveProgress(backupSpine, backupPage, backupPageCount)) { - LOG_ERR("ERS", "Failed to save progress before cache clear"); - } - } + chapterOpen = false; + Storage.removeDir(cacheDir_.c_str()); + Storage.ensureDirectoryExists(cacheDir_.c_str()); + // Preserve the reading position across the wipe. + EpubReaderUtils::saveProgress(cacheDir_, static_cast(currentSpineIndex), lastCharStart); } onGoHome(); return; @@ -734,9 +533,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::BOOKMARKS: { - startActivityForResult( - std::make_unique(renderer, mappedInput, epub, epub->getPath()), - progressChangeResultHandler); + startActivityForResult(std::make_unique(renderer, mappedInput, paginator, path_), + progressChangeResultHandler); break; } case EpubReaderMenuActivity::MenuAction::TOGGLE_BOOKMARK: { @@ -749,75 +547,49 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction bool EpubReaderActivity::launchKOReaderSync() { if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch - const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; - std::optional paragraphIndex; - if (section && currentPage >= 0 && currentPage < section->pageCount) { - const uint16_t paragraphPage = - currentPage > 0 ? static_cast(currentPage - 1) : static_cast(currentPage); - if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) { - paragraphIndex = *pIdx; - } - } + const int totalPages = paginator.chapterReady() ? static_cast(paginator.pageCount()) : 0; - // Pre-compute local KO position and chapter name while Epub is still in RAM. - CrossPointPosition localPos = getCurrentPosition(); - SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos); - const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); - std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : ""; - const std::string savedEpubPath = epub->getPath(); + // 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()}; + const int tocIdx = paginator.tocIndexForSpine(currentSpineIndex); + std::string localChapterName = tocIdx >= 0 ? paginator.tocItem(tocIdx).title : ""; // Persist current position so the reader resumes at the right page on return. - // goToReader() depends on this file, so abort the sync if the write fails. - if (!saveProgress(currentSpineIndex, currentPage, totalPages)) { + if (!EpubReaderUtils::saveProgress(cacheDir_, static_cast(currentSpineIndex), lastCharStart)) { LOG_ERR("KOSync", "Aborting sync because current progress could not be saved"); pendingSyncSaveError = true; requestUpdate(); return true; // acted: surfaced a save error to the user } - // Release Epub and Section to free ~65KB RAM for the TLS handshake. - LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap()); + // Release the book to free RAM for the TLS handshake. + LOG_DBG("KOSync", "Releasing book for sync (heap before: %u)", (unsigned)ESP.getFreeHeap()); { RenderLock lock(*this); - if (section) { - nextPageNumber = section->currentPage; - } - section.reset(); - epub.reset(); + chapterOpen = false; + paginator.close(); } - LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap()); + LOG_DBG("KOSync", "Book released (heap after: %u)", (unsigned)ESP.getFreeHeap()); activityManager.replaceActivity(std::make_unique( - renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos), - std::move(localChapterName), paragraphIndex)); + renderer, mappedInput, path_, currentSpineIndex, static_cast(currentPage), totalPages, std::move(localKoPos), + std::move(localChapterName), std::nullopt)); return true; // acted: launched the sync activity } void EpubReaderActivity::applyOrientation(const uint8_t orientation) { - // No-op if the selected orientation matches current settings. if (SETTINGS.orientation == orientation) { return; } - - // Preserve current reading position so we can restore after reflow. { RenderLock lock(*this); - if (section) { - cachedSpineIndex = currentSpineIndex; - cachedChapterTotalPageCount = section->pageCount; - nextPageNumber = section->currentPage; - } - - // Persist the selection so the reader keeps the new orientation on next launch. SETTINGS.orientation = orientation; SETTINGS.saveToFile(); - - // Update renderer orientation to match the new logical coordinate system. ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - - // Reset section to force re-layout in the new orientation. - section.reset(); + // The generation changes with the page dimensions; render() reanchors on + // lastCharStart automatically. } } @@ -828,63 +600,105 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption } lastPageTurnTime = millis(); - // calculates page turn duration by dividing by number of pages pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_RATES[selectedPageTurnOption]; automaticPageTurnActive = true; - - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only - if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { - // Preserve current reading position so we can restore after reflow. - RenderLock lock(*this); - if (section) { - cachedSpineIndex = currentSpineIndex; - cachedChapterTotalPageCount = section->pageCount; - nextPageNumber = section->currentPage; - } - section.reset(); - } + // The auto-turn indicator reserves status bar space; the viewport change + // shows up in the generation hash and re-paginates on the next render. } void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (isForwardTurn) { - // Advance within the section while there are (or may still be) more pages: either a built - // page ahead, or the section is still building (windowed), in which case more pages exist - // beyond the current watermark and render()'s ensure-built pump will lay them out. Only when - // the section is fully built AND we're on its last page do we move to the next spine -- using - // the live pageCount alone would mistake the build watermark for the end of a giant spine. - if (section->currentPage < section->pageCount - 1 || section->isBuilding()) { - section->currentPage++; + if (paginator.chapterReady() && currentPage + 1 < paginator.pageCount()) { + currentPage++; } else { - // We don't want to delete the section mid-render, so grab the semaphore - { - RenderLock lock(*this); - nextPageNumber = 0; - currentSpineIndex++; - section.reset(); - } + currentSpineIndex++; // spineCount == end-of-book screen + currentPage = 0; } } else { - if (section->currentPage > 0) { - section->currentPage--; + if (currentPage > 0) { + currentPage--; } else if (currentSpineIndex > 0) { - // We don't want to delete the section mid-render, so grab the semaphore - { - RenderLock lock(*this); - nextPageNumber = 0; - pendingPageJump = std::numeric_limits::max(); - currentSpineIndex--; - section.reset(); - } + currentSpineIndex--; + pendingLastPage = true; } } lastPageTurnTime = millis(); requestUpdate(); } +bool EpubReaderActivity::ensureChapterAndPosition() { + const uint32_t gen = paginator.generation(); + if (!chapterOpen || paginator.currentSpine() != currentSpineIndex || openGeneration != gen) { + // Settings/orientation changed under the same chapter: reanchor on the + // page we were showing unless an explicit jump is already pending. + if (chapterOpen && paginator.currentSpine() == currentSpineIndex && openGeneration != gen && + !pendingCharStart.has_value() && !pendingChapterFraction.has_value() && pendingAnchor.empty() && + !pendingLastPage) { + pendingCharStart = lastCharStart; + } + chapterOpen = false; + buildPopupShown = false; + + BookPaginator::BuildProgress progressCb; + progressCb.ctx = this; + progressCb.fn = [](void* ctx, uint32_t) { + auto* self = static_cast(ctx); + if (!self->buildPopupShown) { + GUI.drawPopup(self->renderer, tr(STR_INDEXING)); + // HALF-clear the popup when the page replaces it, else it ghosts. + self->pagesUntilFullRefresh = 1; + self->buildPopupShown = true; + } + }; + + const auto status = paginator.ensureChapter(static_cast(currentSpineIndex), progressCb); + if (status != freeink::book::BookStatus::Ok) { + LOG_ERR("ERS", "ensureChapter(%d) failed: %d", currentSpineIndex, static_cast(status)); + return false; + } + chapterOpen = true; + openGeneration = gen; + } + + // Resolve the landing page for any pending jump. + if (!pendingAnchor.empty()) { + uint32_t anchorChar = 0; + if (paginator.charForAnchor(pendingAnchor.c_str(), &anchorChar)) { + currentPage = paginator.pageForChar(anchorChar); + LOG_DBG("ERS", "Resolved anchor '%s' to page %u", pendingAnchor.c_str(), currentPage); + } else { + LOG_DBG("ERS", "Anchor '%s' not found in spine %d", pendingAnchor.c_str(), currentSpineIndex); + currentPage = 0; + } + pendingAnchor.clear(); + pendingCharStart.reset(); + pendingChapterFraction.reset(); + pendingLastPage = false; + } else if (pendingCharStart.has_value()) { + currentPage = paginator.pageForChar(*pendingCharStart); + pendingCharStart.reset(); + pendingChapterFraction.reset(); + pendingLastPage = false; + } else if (pendingChapterFraction.has_value()) { + const uint32_t targetChar = + static_cast(*pendingChapterFraction * static_cast(paginator.totalChars())); + currentPage = paginator.pageForChar(targetChar); + pendingChapterFraction.reset(); + pendingLastPage = false; + } else if (pendingLastPage) { + currentPage = paginator.pageCount() > 0 ? paginator.pageCount() - 1 : 0; + pendingLastPage = false; + } + + if (paginator.pageCount() > 0 && currentPage >= paginator.pageCount()) { + currentPage = paginator.pageCount() - 1; + } + return true; +} + // TODO: Failure handling void EpubReaderActivity::render(RenderLock&& lock) { - if (!epub) { + if (!paginator.isOpen()) { return; } @@ -894,20 +708,17 @@ void EpubReaderActivity::render(RenderLock&& lock) { GUI.drawPopup(renderer, tr(STR_SAVE_PROGRESS_FAILED)); }; - // edge case handling for sub-zero spine index + const int spineCount = static_cast(paginator.spineCount()); if (currentSpineIndex < 0) { currentSpineIndex = 0; } - // based bounds of book, show end of book screen - if (currentSpineIndex > epub->getSpineItemsCount()) { - currentSpineIndex = epub->getSpineItemsCount(); + if (currentSpineIndex > spineCount) { + currentSpineIndex = spineCount; } // Show end of book screen - if (currentSpineIndex == epub->getSpineItemsCount()) { - // Sole load site: runs on the render task (serialized by RenderLock); the main - // task only reads the suggestions once the loaded flag is published - endOfBookOptions.loadOnce(epub->getPath()); + if (currentSpineIndex == spineCount) { + endOfBookOptions.loadOnce(path_); renderer.clearScreen(); endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); @@ -925,8 +736,6 @@ void EpubReaderActivity::render(RenderLock&& lock) { orientedMarginRight += SETTINGS.screenMargin; const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - - // reserves space for automatic page turn indicator when no status bar or progress bar only if (automaticPageTurnActive && (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { orientedMarginBottom += @@ -936,211 +745,25 @@ void EpubReaderActivity::render(RenderLock&& lock) { orientedMarginBottom += std::max(SETTINGS.screenMargin, statusBarHeight); } - const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; - const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + // The engine lays out inside [margin..page-margin]; run coordinates arrive + // in absolute screen space, so no offset is applied at draw time. + paginator.configureLayout(static_cast(renderer.getScreenWidth()), + static_cast(renderer.getScreenHeight()), static_cast(orientedMarginLeft), + static_cast(orientedMarginRight), static_cast(orientedMarginTop), + static_cast(orientedMarginBottom)); - if (!section) { - const auto filepath = epub->getSpineItem(currentSpineIndex).href; - LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex); - section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); - - // A finalized cache serves every page as-is. A partial cache (suspended build from a - // previous session) serves its pages instantly too, but a build must still run to lay - // out the rest -- it re-parses from the top in the background (HTML already cached, - // pages are deterministic) and finalizes, so the partial machinery retires itself. - const bool cacheLoaded = section->loadSectionFile( - SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, - SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled); - if (cacheLoaded) { - // Matching render params means identical pagination, so the saved page number is valid - // as-is: consume any pending settings-change reposition. Without this, a chapter total - // saved while the section was still building (i.e. a watermark, not the real count) - // would remap the resume page against the finalized count and teleport the reader. - cachedChapterTotalPageCount = 0; - } - const bool cacheComplete = cacheLoaded && !section->isPartial(); - if (!cacheComplete) { - if (section->isPartial()) { - LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount); - } else { - LOG_DBG("ERS", "Cache not found, building..."); - } - - // Jumps that need the final pagination or the anchor map -- explicit page jumps, - // fragment anchors, percent jumps, and cross-setting progress repositioning -- can't - // resolve their landing page until the whole chapter is laid out, so they take the full - // (blocking) build with the indexing popup. Everything else -- plain forward reads, resume, - // and explicit page jumps -- only needs a specific page, so it builds incrementally to that - // page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*) - // is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page - // count is known, so it never blocks the first page. - // Only a percent jump truly needs the whole chapter up front (percent -> page needs the final - // page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below -- - // the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0 - // without indexing the whole chapter. - const bool needsFullBuild = pendingPercentJump; - if (needsFullBuild) { - GUI.drawPopup(renderer, tr(STR_INDEXING)); - // The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF - // ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page. - pagesUntilFullRefresh = 1; - const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); }; - if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) { - LOG_ERR("ERS", "Failed to persist page data to SD"); - section.reset(); - showPendingSyncSaveError(); - return; - } - } else { - // Lay out just enough to show the landing page; loop() builds the rest behind it. Show the - // indexing popup up front only when the build will actually be slow: a large spine (its - // whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or - // a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections - // build in a blink and stay popup-free. - const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber); - const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) - - (currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0); - // Popup only when the build will actually be slow: a big spine whose HTML still needs - // inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds - // fast, so no popup -- that's what made an already-indexed book look like it was reindexing. - // A partial cache that already covers the target page shows it instantly: never popup. - const bool willInflate = !section->hasHtmlCache(); - const bool anchorJump = !pendingAnchor.empty(); - bool showPopup; - if (anchorJump) { - // An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already - // in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it - // lies beyond the indexed watermark and the build may lay out the whole spine to find it, - // so gate on spine size alone -- laying out a big spine takes seconds even with cached - // HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free. - showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD; - } else { - const bool targetAvailable = target < static_cast(section->pageCount); - showPopup = !targetAvailable && - ((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD); - } - if (showPopup) { - GUI.drawPopup(renderer, tr(STR_INDEXING)); - // HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page. - pagesUntilFullRefresh = 1; - } - if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { - LOG_ERR("ERS", "Failed to start section build"); - section.reset(); - showPendingSyncSaveError(); - return; - } - while (!section->isBuildComplete() && - (anchorJump ? !section->findAnchor(pendingAnchor) : static_cast(section->pageCount) <= target)) { - // Anchor jump: build until the anchor's page is laid out (usually page 0), checking a - // partial's on-disk anchor map too so an already-indexed anchor resolves immediately. - // Otherwise: build until the target page exists. loop() builds the rest behind it. - if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { - LOG_ERR("ERS", "Failed during incremental section build"); - section.reset(); - showPendingSyncSaveError(); - return; - } - } - } - } else { - LOG_DBG("ERS", "Cache found, skipping build..."); - } - - if (pendingPageJump.has_value()) { - section->currentPage = *pendingPageJump; - pendingPageJump.reset(); - } else { - section->currentPage = nextPageNumber; - if (section->currentPage < 0) { - section->currentPage = 0; - } - } - - if (!pendingAnchor.empty()) { - // Resolve from the pages laid out so far and/or the on-disk map (finalized or partial). - const auto page = section->findAnchor(pendingAnchor); - if (page) { - section->currentPage = *page; - LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page); - } else { - LOG_DBG("ERS", "Anchor '%s' not found in section %d", pendingAnchor.c_str(), currentSpineIndex); - } - pendingAnchor.clear(); - } - - if (pendingPercentJump && section->pageCount > 0) { - // Apply the pending percent jump now that we know the new section's page count. - int newPage = static_cast(pendingSpineProgress * static_cast(section->pageCount)); - if (newPage >= section->pageCount) { - newPage = section->pageCount - 1; - } - section->currentPage = newPage; - pendingPercentJump = false; - } + if (!ensureChapterAndPosition()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + automaticPageTurnActive = false; + showPendingSyncSaveError(); + return; } - // Extend the build to the requested page if needed (for partials and in-progress builds). - // This runs every render, so it covers both the first page and any forward turn that gets - // ahead of the background builder; pages already built do no work here. - while (section->isPartial() && section->currentPage >= static_cast(section->pageCount)) { - // Start a build to extend a partial toward the requested page. - if (!section->isBuilding() && - !section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, - SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, - SETTINGS.focusReadingEnabled)) { - LOG_ERR("ERS", "Failed to start partial extension build"); - section.reset(); - showPendingSyncSaveError(); - return; - } - // Extend until either the target page exists or the build completes. - while (!section->isBuildComplete() && section->currentPage >= static_cast(section->pageCount)) { - if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { - LOG_ERR("ERS", "Failed during incremental section build"); - section.reset(); - showPendingSyncSaveError(); - return; - } - } - } - // For an in-progress incremental build, make sure the page we're about to show has been laid out. - if (section->isBuilding()) { - while (!section->isBuildComplete() && section->currentPage >= static_cast(section->pageCount)) { - if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) { - LOG_ERR("ERS", "Failed during incremental section build"); - section.reset(); - showPendingSyncSaveError(); - return; - } - } - } - - // The requested page is now as built as it will get. If it still lands past the end, - // clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter - // navigation, an explicit jump beyond a finished chapter, or a stale saved position. - // Guarded on !isBuilding() because a still-building section's pageCount is only the current - // watermark (not the final count) and has already been driven far enough by the loops above. - if (!section->isBuilding() && section->pageCount > 0 && - section->currentPage >= static_cast(section->pageCount)) { - section->currentPage = section->pageCount - 1; - } - - // Apply a deferred settings-change reposition now that the real page count is known (a no-op for - // a plain resume / unchanged pagination). If still building, this defers to loop() on completion. - applyDeferredReposition(); - renderer.clearScreen(); - if (section->pageCount == 0) { + if (paginator.pageCount() == 0) { LOG_DBG("ERS", "No pages to render"); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); renderStatusBar(); @@ -1150,44 +773,26 @@ void EpubReaderActivity::render(RenderLock&& lock) { return; } - if (section->currentPage < 0 || section->currentPage >= section->pageCount) { - LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD); - renderStatusBar(); - renderer.displayBuffer(); + freeink::book::Page page{}; + const auto readStatus = paginator.readPage(currentPage, &page); + if (readStatus != freeink::book::BookStatus::Ok) { + LOG_ERR("ERS", "Failed to read page %u (%d) - clearing chapter cache", currentPage, static_cast(readStatus)); + chapterOpen = false; + requestUpdate(); // rebuild on the next pass automaticPageTurnActive = false; showPendingSyncSaveError(); return; } + lastCharStart = page.charStart; + currentPageFootnotes = FreeInkPageRenderer::collectFootnotes(page); updateBookmarkFlag(); - { - // Unified page read: the in-progress build's in-RAM table if it has reached the page, - // otherwise the on-disk file (finalized section, or a partial from a previous session). - auto p = section->loadPage(section->currentPage); - if (!p) { - LOG_ERR("ERS", "Failed to load page from SD - clearing section cache"); - // Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files, - // and the destructor's suspend would otherwise commit tables into a deleted handle. - section->abandonBuild(); - section->clearCache(); - section.reset(); - requestUpdate(); // Try again after clearing cache - // TODO: prevent infinite loop if the page keeps failing to load for some reason - automaticPageTurnActive = false; - showPendingSyncSaveError(); - return; - } + const auto start = millis(); + renderPage(page, 0); + LOG_DBG("ERS", "Rendered page in %dms", millis() - start); - // Collect footnotes from the loaded page - currentPageFootnotes = std::move(p->footnotes); - - const auto start = millis(); - renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); - LOG_DBG("ERS", "Rendered page in %dms", millis() - start); - } - saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages()); + EpubReaderUtils::saveProgress(cacheDir_, static_cast(currentSpineIndex), lastCharStart); showPendingSyncSaveError(); @@ -1201,99 +806,48 @@ void EpubReaderActivity::render(RenderLock&& lock) { } } -bool EpubReaderActivity::applyDeferredReposition() { - if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) { - return false; - } - bool changed = false; - // Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain - // resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and - // nothing moves. - if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { - const float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); - int newPage = static_cast(progress * static_cast(section->pageCount)); - if (newPage < 0) newPage = 0; - if (section->pageCount > 0 && newPage >= static_cast(section->pageCount)) { - newPage = section->pageCount - 1; - } - if (newPage != section->currentPage) { - section->currentPage = newPage; - changed = true; - } - } - cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again - return changed; -} - -bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { - return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount); -} -void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, - const int orientedMarginRight, const int orientedMarginBottom, - const int orientedMarginLeft) { +void EpubReaderActivity::renderPage(const freeink::book::Page& page, int) { const auto t0 = millis(); - const int fontId = SETTINGS.getReaderFontId(); // Font prewarm: scan pass accumulates text, then prewarm, then real render auto* fcm = renderer.getFontCacheManager(); auto scope = fcm->createPrewarmScope(); - page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); // scan pass + FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); // scan pass scope.endScanAndPrewarm(); const auto tPrewarm = millis(); - const bool pageHasImages = page->hasImages(); + const bool pageHasImages = + FreeInkPageRenderer::hasImages(page) && SETTINGS.imageRendering == CrossPointSettings::IMAGES_DISPLAY; const bool needsTextGrayscale = SETTINGS.textAntiAliasing; const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages; - auto renderGrayscalePass = [&]() { - if (needsTextGrayscale) { - page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); - } else { - page->renderImages(renderer, fontId, orientedMarginLeft, orientedMarginTop); - } - }; + auto renderGrayscalePass = [&]() { FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); }; - page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); + FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); renderStatusBar(); const auto tBwRender = millis(); if (pageHasImages) { // Double FAST_REFRESH with selective image blanking (pablohc's technique): - // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust. - // Instead, blank only the image area and do two fast refreshes. - // Step 1: Display page with image area blanked (text appears, image area white) - // Step 2: Re-render with images and display again (images appear clean) + // blank only the image area, fast refresh, re-render, fast refresh again. int16_t imgX, imgY, imgW, imgH; - if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { - renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false); + if (FreeInkPageRenderer::imageBoundingBox(page, &imgX, &imgY, &imgW, &imgH)) { + renderer.fillRect(imgX, imgY, imgW, imgH, false); renderer.displayBuffer(HalDisplay::FAST_REFRESH); - - // Re-render page content to restore images into the blanked area - // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %) - page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); + FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } - // The image's own page is handled above and doesn't count toward the full - // refresh cadence. But the grayscale pass below leaves gray charge in the - // image region that a plain fast diff on the *next* page can't clear, so - // text there ghosts gray (#2190). Force the next ordinary page onto the - // HALF ghost-cleanup path, which drives every pixel to its target - // regardless of residue. + // Grayscale leaves residue a plain fast diff can't clear (#2190): force + // the next ordinary page onto the HALF ghost-cleanup path. pagesUntilFullRefresh = 1; } else { ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); } const auto tDisplay = millis(); - // Tiled grayscale: render each plane band-by-band into a small scratch and - // stream straight to the controller, leaving the BW framebuffer intact so no - // full-frame storeBwBuffer is needed; controller RAM is re-synced from the - // live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times - // per plane, but renderCharImpl culls out-of-band glyphs before decode so the - // cost stays close to one render. Both text (drawPixel) and images - // (DirectPixelWriter) honor the active strip target. if (needsAnyGrayscale && renderer.supportsStripGrayscale()) { + // Tiled grayscale: render each plane band-by-band into a small scratch. constexpr int STRIP_ROWS = 80; const int gh = renderer.getDisplayHeight(); const int gwBytes = renderer.getDisplayWidthBytes(); @@ -1302,8 +856,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or if (!scratch) { LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); } else { - // Bands may be streamed in any order: X4 windows each via setRamArea, X3 - // via PTL. renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); for (int y = 0; y < gh; y += STRIP_ROWS) { const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; @@ -1313,9 +865,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or renderer.endStripTarget(); renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows); } - const auto tGrayLsb = millis(); - - // MSB plane. renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); for (int y = 0; y < gh; y += STRIP_ROWS) { const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; @@ -1325,139 +874,97 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or renderer.endStripTarget(); renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows); } - const auto tGrayMsb = millis(); - renderer.setRenderMode(GfxRenderer::BW); renderer.displayGrayBuffer(); - const auto tGrayDisplay = millis(); - - // BW framebuffer is intact; re-sync controller RAM for the next - // differential page turn directly from it. renderer.cleanupGrayscaleWithFrameBuffer(); - const auto tCleanup = millis(); - - const auto tEnd = millis(); - LOG_DBG("ERS", - "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums " - "gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, - tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); + LOG_DBG("ERS", "Page render (tiled): prewarm=%lums bw=%lums display=%lums total=%lums", tPrewarm - t0, + tBwRender - tPrewarm, tDisplay - tBwRender, millis() - t0); } + } else if (needsAnyGrayscale) { + // Fallback for controllers without strip support: full-frame plane swaps. + if (!renderer.storeBwBuffer()) { + LOG_ERR("ERS", "Failed to store BW buffer for grayscale render; skipping grayscale this page"); + return; + } + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderGrayscalePass(); + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderGrayscalePass(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + renderer.restoreBwBuffer(); + LOG_DBG("ERS", "Page render: prewarm=%lums bw=%lums display=%lums total=%lums", tPrewarm - t0, tBwRender - tPrewarm, + tDisplay - tBwRender, millis() - t0); } else { - // Fallback path for a controller without strip support. grayscale rendering - // TODO: Only do this if font supports it - if (needsAnyGrayscale) { - // Save the BW frame before the grayscale passes overwrite it, restore - // after. Only needed when grayscale actually renders. - if (!renderer.storeBwBuffer()) { - LOG_ERR("ERS", "Failed to store BW buffer for grayscale render; skipping grayscale this page"); - const auto tEnd = millis(); - LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0, - tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0); - return; - } - const auto tBwStore = millis(); - - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - renderGrayscalePass(); - renderer.copyGrayscaleLsbBuffers(); - const auto tGrayLsb = millis(); - - // Render and copy to MSB buffer - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - renderGrayscalePass(); - renderer.copyGrayscaleMsbBuffers(); - const auto tGrayMsb = millis(); - - // display grayscale part - renderer.displayGrayBuffer(); - const auto tGrayDisplay = millis(); - renderer.setRenderMode(GfxRenderer::BW); - renderer.restoreBwBuffer(); - const auto tBwRestore = millis(); - - const auto tEnd = millis(); - LOG_DBG("ERS", - "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " - "gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore, - tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0); - } else { - // No text AA and no images: BW frame already displayed above, no grayscale - // to render, so no save/restore. - const auto tEnd = millis(); - LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0, - tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0); - } + LOG_DBG("ERS", "Page render: prewarm=%lums bw=%lums display=%lums total=%lums", tPrewarm - t0, tBwRender - tPrewarm, + tDisplay - tBwRender, millis() - t0); } } void EpubReaderActivity::renderStatusBar() const { - // Calculate progress in book. Use the estimated total while a giant spine is still building so - // "page X of Y" and the progress bar don't read off the small build watermark. - const int currentPage = section->currentPage + 1; - const float pageCount = section->estimatedTotalPages(); - const float sectionChapterProg = (pageCount > 0) ? (static_cast(currentPage) / pageCount) : 0; - const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; + const int currentPageDisplay = static_cast(currentPage) + 1; + const float pageCount = paginator.chapterReady() ? static_cast(paginator.pageCount()) : 0.0f; + const float bookProgress = currentBookFraction() * 100.0f; std::string title; - int textYOffset = 0; if (automaticPageTurnActive) { title = tr(STR_AUTO_TURN_ENABLED) + std::to_string(60 * 1000 / pageTurnDuration); - - // calculates textYOffset when rendering title in status bar const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - - // offsets text if no status bar or progress bar only if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { textYOffset += UITheme::getInstance().getMetrics().statusBarVerticalMargin; } - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { title = tr(STR_UNNAMED); - const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + const int tocIndex = paginator.tocIndexForSpine(currentSpineIndex); if (tocIndex != -1) { - const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; + title = paginator.tocItem(tocIndex).title; } - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { - title = epub->getTitle(); + title = paginator.title(); } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked, - section->isBuilding()); + GUI.drawStatusBar(renderer, bookProgress, currentPageDisplay, pageCount, title, 0, textYOffset, true, + currentPageBookmarked, false); +} + +std::string EpubReaderActivity::currentPageText() { + RenderLock lock(*this); + if (!paginator.chapterReady()) return ""; + freeink::book::Page page{}; + if (paginator.readPage(currentPage, &page) != freeink::book::BookStatus::Ok) return ""; + return FreeInkPageRenderer::pageText(page); } void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { - if (!epub) return; - // Push current position onto saved stack - if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) { - savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage}; + if (savePosition && footnoteDepth < MAX_FOOTNOTE_DEPTH) { + savedPositions[footnoteDepth] = {currentSpineIndex, lastCharStart}; footnoteDepth++; - LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage); + LOG_DBG("ERS", "Saved position [%d]: spine %d, char %u", footnoteDepth, currentSpineIndex, + static_cast(lastCharStart)); } - // Extract fragment anchor (e.g. "#note1" or "chapter2.xhtml#note1") + // Split "path#fragment" (link targets arrive container-resolved from the + // page records; a bare "#frag" targets the current chapter). + std::string target = hrefStr; std::string anchor; const auto hashPos = hrefStr.find('#'); - if (hashPos != std::string::npos && hashPos + 1 < hrefStr.size()) { + if (hashPos != std::string::npos) { anchor = hrefStr.substr(hashPos + 1); + target = hrefStr.substr(0, hashPos); } - // Check for same-file anchor reference (#anchor only) - bool sameFile = !hrefStr.empty() && hrefStr[0] == '#'; - - int targetSpineIndex; - if (sameFile) { - targetSpineIndex = currentSpineIndex; - } else { - targetSpineIndex = epub->resolveHrefToSpineIndex(hrefStr); + int targetSpineIndex = currentSpineIndex; + if (!target.empty()) { + targetSpineIndex = paginator.spineIndexForHref(target.c_str()); } if (targetSpineIndex < 0) { @@ -1466,13 +973,11 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s return; } - { - RenderLock lock(*this); - pendingAnchor = std::move(anchor); - currentSpineIndex = targetSpineIndex; - nextPageNumber = 0; - section.reset(); - } + currentSpineIndex = targetSpineIndex; + pendingAnchor = std::move(anchor); + pendingCharStart.reset(); + pendingChapterFraction.reset(); + currentPage = 0; requestUpdate(); LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, hrefStr.c_str()); } @@ -1481,14 +986,10 @@ void EpubReaderActivity::restoreSavedPosition() { if (footnoteDepth <= 0) return; footnoteDepth--; const auto& pos = savedPositions[footnoteDepth]; - LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber); - - { - RenderLock lock(*this); - currentSpineIndex = pos.spineIndex; - nextPageNumber = pos.pageNumber; - section.reset(); - } + LOG_DBG("ERS", "Restoring position [%d]: spine %d, char %u", footnoteDepth, pos.spineIndex, + static_cast(pos.charStart)); + currentSpineIndex = pos.spineIndex; + pendingCharStart = pos.charStart; requestUpdate(); } @@ -1497,12 +998,8 @@ void EpubReaderActivity::loadCachedBookmarks() { if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) { cachedBookmarks.reserve(initialBookmarkCacheCapacity); } - if (!epub) { - currentPageBookmarked = false; - return; - } - const std::string bmPath = BookmarkUtil::getBookmarkPath(epub->getPath()); + const std::string bmPath = BookmarkUtil::getBookmarkPath(path_); if (Storage.exists(bmPath.c_str())) { String json = Storage.readFile(bmPath.c_str()); if (!json.isEmpty()) { @@ -1512,108 +1009,95 @@ void EpubReaderActivity::loadCachedBookmarks() { updateBookmarkFlag(); } +// True when `b` points inside the page currently shown: exact char-range +// containment for engine-era bookmarks, book-percentage proximity for legacy +// entries written by the old engine. +static bool bookmarkOnCurrentPage(const BookmarkEntry& b, const int spineIndex, const uint32_t pageStartChar, + const uint32_t pageEndChar, const float pageStartPct, const float pageEndPct) { + if (b.hasCharStart) { + return b.computedSpineIndex == spineIndex && b.charStart >= pageStartChar && b.charStart < pageEndChar; + } + constexpr float kEpsilon = 0.0001f; + const float pct = std::clamp(b.percentage, 0.0f, 1.0f); + return pct + kEpsilon >= pageStartPct && pct - kEpsilon <= pageEndPct; +} + void EpubReaderActivity::addBookmark() { - if (!section || !epub) { + if (!paginator.chapterReady()) { return; } - LOG_DBG("ERS", "Toggle bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1); - int currentPage; - int pageCount; - { - RenderLock lock(*this); - pageCount = section->estimatedTotalPages(); - currentPage = section->currentPage; - } + LOG_DBG("ERS", "Toggle bookmark at spine %d, char %u", currentSpineIndex, static_cast(lastCharStart)); - SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition()); - const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount); + const uint32_t totalChars = paginator.totalChars(); + const uint32_t pageStartChar = paginator.charStartOfPage(currentPage); + const uint32_t pageEndChar = + currentPage + 1 < paginator.pageCount() ? paginator.charStartOfPage(currentPage + 1) : totalChars + 1; + const float pageStartPct = + paginator.bookProgress(currentSpineIndex, totalChars ? static_cast(pageStartChar) / totalChars : 0.0f); + const float pageEndPct = + paginator.bookProgress(currentSpineIndex, totalChars ? static_cast(pageEndChar) / totalChars : 0.0f); const size_t bookmarkCountBeforeToggle = cachedBookmarks.size(); cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) { - return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount, - pageRange); + return bookmarkOnCurrentPage(b, currentSpineIndex, pageStartChar, pageEndChar, + pageStartPct, pageEndPct); }), cachedBookmarks.end()); if (cachedBookmarks.size() != bookmarkCountBeforeToggle) { bookmarkRemoved = true; currentPageBookmarked = false; } else { - std::string pageText; - if (currentPage >= 0 && currentPage < pageCount) { - pageText = section->getTextFromSectionFile(); - } BookmarkEntry entry; - entry.percentage = progress.percentage; - entry.xpath = progress.xpath; - entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText); + entry.percentage = currentBookFraction(); + entry.xpath = syntheticXPath(currentSpineIndex); + entry.summary = BookmarkUtil::sanitizeBookmarkSummary(currentPageText()); entry.computedSpineIndex = currentSpineIndex; - entry.computedChapterPageCount = pageCount; - entry.computedChapterProgress = currentPage; + entry.computedChapterPageCount = static_cast(std::min(paginator.pageCount(), UINT16_MAX)); + entry.computedChapterProgress = static_cast(std::min(currentPage, UINT16_MAX)); + entry.charStart = lastCharStart; + entry.hasCharStart = true; cachedBookmarks.insert(cachedBookmarks.begin(), entry); bookmarkRemoved = false; currentPageBookmarked = true; } - const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath()); + const std::string path = BookmarkUtil::getBookmarkPath(path_); const std::string bookmarksDir = BookmarkUtil::getBookmarksDir(); Storage.mkdir(bookmarksDir.c_str()); - const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str()); - if (!ok) { + if (!JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str())) { LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str()); } requestUpdate(); } void EpubReaderActivity::updateBookmarkFlag() { - if (!section || !epub || cachedBookmarks.empty()) { + if (!paginator.chapterReady() || cachedBookmarks.empty()) { currentPageBookmarked = false; return; } - const int pageCount = section->estimatedTotalPages(); - const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount); + const uint32_t totalChars = paginator.totalChars(); + const uint32_t pageStartChar = paginator.charStartOfPage(currentPage); + const uint32_t pageEndChar = + currentPage + 1 < paginator.pageCount() ? paginator.charStartOfPage(currentPage + 1) : totalChars + 1; + const float pageStartPct = + paginator.bookProgress(currentSpineIndex, totalChars ? static_cast(pageStartChar) / totalChars : 0.0f); + const float pageEndPct = + paginator.bookProgress(currentSpineIndex, totalChars ? static_cast(pageEndChar) / totalChars : 0.0f); currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) { - return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange); + return bookmarkOnCurrentPage(b, currentSpineIndex, pageStartChar, pageEndChar, pageStartPct, pageEndPct); }); } ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const { ScreenshotInfo info; info.readerType = ScreenshotInfo::ReaderType::Epub; - if (epub) { - snprintf(info.title, sizeof(info.title), "%s", epub->getTitle().c_str()); - info.spineIndex = currentSpineIndex; - } - if (section) { - info.currentPage = section->currentPage + 1; - info.totalPages = section->estimatedTotalPages(); - if (epub && epub->getBookSize() > 0 && info.totalPages > 0) { - const float chapterProgress = static_cast(section->currentPage) / static_cast(info.totalPages); - int pct = static_cast(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f); - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - info.progressPercent = pct; - } + snprintf(info.title, sizeof(info.title), "%s", paginator.title()); + info.spineIndex = currentSpineIndex; + if (paginator.chapterReady()) { + info.currentPage = static_cast(currentPage) + 1; + info.totalPages = static_cast(paginator.pageCount()); + info.progressPercent = clampPercent(static_cast(currentBookFraction() * 100.0f + 0.5f)); } return info; } - -CrossPointPosition EpubReaderActivity::getCurrentPosition() const { - const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; - std::optional paragraphIndex; - if (section && currentPage >= 0 && currentPage < section->pageCount) { - const uint16_t paragraphPage = - currentPage > 0 ? static_cast(currentPage - 1) : static_cast(currentPage); - if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) { - paragraphIndex = *pIdx; - } - } - - CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages}; - if (paragraphIndex.has_value()) { - localPos.paragraphIndex = *paragraphIndex; - localPos.hasParagraphIndex = true; - } - return localPos; -} diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 503bb23e..37651a17 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -1,100 +1,79 @@ #pragma once -#include #include -#include #include +#include +#include +#include "BookPaginator.h" #include "BookmarkEntry.h" #include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" -#include "ProgressMapper.h" #include "activities/Activity.h" +// EPUB reading UI over the FreeInkBook engine (BookPaginator). Position is +// tracked as (spineIndex, charStart) — the chapter character offset of the +// current page — which survives every layout-parameter change; the page +// number is derived per generation via pageForChar(). class EpubReaderActivity final : public Activity { - std::shared_ptr epub; - std::unique_ptr
section = nullptr; + std::string path_; + std::string cacheDir_; + BookPaginator paginator; + int currentSpineIndex = 0; - int nextPageNumber = 0; - std::optional 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. + uint32_t currentPage = 0; + // charStart of the page currently shown — the anchor that re-derives the + // page after any re-pagination (settings/orientation change). + uint32_t lastCharStart = 0; + // The generation the open chapter was paginated for; a mismatch in render() + // (settings changed while a menu was up) triggers reopen + reanchor. + uint32_t openGeneration = 0; + bool chapterOpen = false; + + // Pending landing position, applied once the target chapter's cache is + // open (charStart wins over fraction; anchor wins over both). + std::optional pendingCharStart; + std::optional pendingChapterFraction; + bool pendingLastPage = false; std::string pendingAnchor; + int pagesUntilFullRefresh = 0; - int cachedSpineIndex = 0; - int cachedChapterTotalPageCount = 0; unsigned long lastPageTurnTime = 0UL; unsigned long pageTurnDuration = 0UL; - // Signals that the next render should reposition within the newly loaded section - // based on a cross-book percentage jump. - bool pendingPercentJump = false; - // Normalized 0.0-1.0 progress within the target spine item, computed from book percentage. - float pendingSpineProgress = 0.0f; bool pendingScreenshot = false; bool pendingSyncSaveError = false; - bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; bool showBookmarkMessage = false; bool ignoreNextConfirmRelease = false; bool currentPageBookmarked = false; bool bookmarkRemoved = false; // true when last toggle removed (controls popup text) + bool buildPopupShown = false; // indexing popup drawn for the current build std::vector cachedBookmarks; - // Tracks whether this book is currently removed from Recent Books by the - // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). bool recentsEntryRemoved = false; unsigned long bookmarkMessageTime = 0UL; - // Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on. - // Consumed in onExit() to relocate the finished book into /Read/. bool pendingReadFolderMove = false; - // Next-book suggestion menu for the End-of-Book screen EndOfBookOptions endOfBookOptions; // Footnote support std::vector currentPageFootnotes; struct SavedPosition { int spineIndex; - int pageNumber; + uint32_t charStart; }; static constexpr int MAX_FOOTNOTE_DEPTH = 3; SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; int footnoteDepth = 0; - void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, - int orientedMarginBottom, int orientedMarginLeft); + // Opens (paginating if needed) the chapter for currentSpineIndex under the + // current generation and resolves pending landing state into currentPage. + bool ensureChapterAndPosition(); + void renderPage(const freeink::book::Page& page, int statusBarSpace); void renderStatusBar() const; - // Pages laid out per incremental-build pump: on the render path (catching up to the page - // being shown) and per loop() tick (background build of a large chapter). Kept small so a - // background build chunk never noticeably delays input or a pending render. - static constexpr int BUILD_PAGES_PER_CHUNK = 8; - static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2; - // How many pages to keep laid out ahead of the reader for a still-building section. A page - // turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder - // -- a tiny buffer is enough. The background build stops once the watermark is this far - // ahead and resumes as the reader advances; building unbounded instead locked up input by - // monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin - // in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages - // already laid out as a partial file on exit/sleep. - static constexpr int BUILD_WINDOW_AHEAD = 5; - // Show the indexing popup when an initial build must lay out more than this many pages up front - // (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent - // of the small look-ahead window so ordinary landings stay popup-free. - static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20; - // Also show the popup when first building a spine larger than this (uncompressed bytes): its - // whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is - // a multi-second wait. Normal chapters are well under this and stay popup-free. - static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024; - // Remap the cached relative reading position once the section's real page count is known - // (used after a settings change re-paginates a chapter). Returns true if currentPage moved. - // No-op while the section is still building or when the pagination is unchanged (plain resume). - bool applyDeferredReposition(); - bool saveProgress(int spineIndex, int currentPage, int pageCount); - // Jump to a percentage of the book (0-100), mapping it to spine and page. + bool saveProgress(); + float currentBookFraction() const; void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); - // Opens the reader menu for the current position (short-press Confirm) void openReaderMenu(); - // Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op - // because no KOReader credentials are stored. bool launchKOReaderSync(); void applyOrientation(uint8_t orientation); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); @@ -102,19 +81,19 @@ class EpubReaderActivity final : public Activity { void loadCachedBookmarks(); void addBookmark(); void updateBookmarkFlag(); + std::string currentPageText(); // Footnote navigation void navigateToHref(const std::string& href, bool savePosition = false); void restoreSavedPosition(); public: - explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub) - : Activity("EpubReader", renderer, mappedInput), epub(std::move(epub)) {} + explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) + : Activity("EpubReader", renderer, mappedInput), path_(std::move(path)) {} void onEnter() override; void onExit() override; void loop() override; void render(RenderLock&& lock) override; bool isReaderActivity() const override { return true; } ScreenshotInfo getScreenshotInfo() const override; - CrossPointPosition getCurrentPosition() const; }; diff --git a/src/activities/reader/EpubReaderBookmarksActivity.cpp b/src/activities/reader/EpubReaderBookmarksActivity.cpp index fcf44cf3..7118bbde 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.cpp +++ b/src/activities/reader/EpubReaderBookmarksActivity.cpp @@ -25,10 +25,6 @@ constexpr int LINE_HEIGHT = 60; void EpubReaderBookmarksActivity::onEnter() { Activity::onEnter(); - if (!epub) { - return; - } - const std::string path = BookmarkUtil::getBookmarkPath(epubPath); if (Storage.exists(path.c_str())) { String json = Storage.readFile(path.c_str()); @@ -111,8 +107,14 @@ void EpubReaderBookmarksActivity::loop() { result.xpath = bookmark.xpath; result.percentage = bookmark.percentage; result.hasSavedProgress = true; - if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount && - bookmark.computedSpineIndex < epub->getSpineItemsCount()) { + if (bookmark.hasCharStart && bookmark.computedSpineIndex < paginator.spineCount()) { + // FreeInkBook locator: exact landing at any layout settings. + result.spineIndex = bookmark.computedSpineIndex; + result.charStart = bookmark.charStart; + result.hasCharStart = true; + } else if (bookmark.computedChapterPageCount > 0 && + bookmark.computedChapterProgress < bookmark.computedChapterPageCount && + bookmark.computedSpineIndex < paginator.spineCount()) { result.spineIndex = bookmark.computedSpineIndex; result.page = bookmark.computedChapterProgress; result.totalPages = bookmark.computedChapterPageCount; @@ -192,8 +194,8 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) { }; const auto getBookmarkSubtitle = [this](int index) { auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index); - auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex); - auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED); + const int tocIndex = paginator.tocIndexForSpine(bookmark.computedSpineIndex); + const std::string tocTitle = (tocIndex >= 0) ? paginator.tocItem(tocIndex).title : tr(STR_UNNAMED); std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - "; if (bookmark.computedChapterPageCount > 0) { subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" + diff --git a/src/activities/reader/EpubReaderBookmarksActivity.h b/src/activities/reader/EpubReaderBookmarksActivity.h index 56e57711..ce337a1f 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.h +++ b/src/activities/reader/EpubReaderBookmarksActivity.h @@ -1,14 +1,11 @@ #pragma once -#include - -#include - #include "../../BookmarkEntry.h" #include "../Activity.h" +#include "BookPaginator.h" #include "util/ButtonNavigator.h" class EpubReaderBookmarksActivity final : public Activity { - std::shared_ptr epub; + BookPaginator& paginator; std::string epubPath; ButtonNavigator buttonNavigator; int selectorIndex = 0; @@ -16,9 +13,9 @@ class EpubReaderBookmarksActivity final : public Activity { int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete public: - explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::shared_ptr& epub, const std::string& epubPath) - : Activity("EpubReaderBookmarks", renderer, mappedInput), epub(epub), epubPath(epubPath) {} + explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, BookPaginator& paginator, + const std::string& epubPath) + : Activity("EpubReaderBookmarks", renderer, mappedInput), paginator(paginator), epubPath(epubPath) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 6306346c..ae09a3cd 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -7,21 +7,16 @@ #include "components/UITheme.h" #include "fontIds.h" -int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); } +int EpubReaderChapterSelectionActivity::getTotalItems() const { return static_cast(paginator.tocCount()); } void EpubReaderChapterSelectionActivity::onEnter() { Activity::onEnter(); - if (!epub) { - return; - } - - selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + selectorIndex = paginator.tocIndexForSpine(currentSpineIndex); if (selectorIndex == -1) { selectorIndex = 0; } - // Trigger first update requestUpdate(); } @@ -32,14 +27,14 @@ void EpubReaderChapterSelectionActivity::loop() { const int totalItems = getTotalItems(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - const auto tocItem = epub->getTocItem(selectorIndex); + const auto tocItem = paginator.tocItem(selectorIndex); if (tocItem.spineIndex == -1) { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); } else { - setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor}); + setResult(ChapterResult{tocItem.spineIndex, tocItem.fragment != nullptr ? tocItem.fragment : ""}); finish(); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -85,8 +80,8 @@ void EpubReaderChapterSelectionActivity::render(RenderLock&&) { const int totalItems = getTotalItems(); GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, selectorIndex, [this](int index) { - auto item = epub->getTocItem(index); - std::string indent((item.level - 1) * 2, ' '); + const auto item = paginator.tocItem(index); + std::string indent(static_cast(item.depth) * 2, ' '); return indent + item.title; }); diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index 9d593e30..bf6bc88e 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -1,32 +1,22 @@ #pragma once -#include - -#include - +#include "BookPaginator.h" #include "activities/Activity.h" #include "util/ButtonNavigator.h" class EpubReaderChapterSelectionActivity final : public Activity { - std::shared_ptr epub; - std::string epubPath; + BookPaginator& paginator; ButtonNavigator buttonNavigator; int currentSpineIndex = 0; int selectorIndex = 0; - // Number of items that fit on a page, derived from logical screen height. - // This adapts automatically when switching between portrait and landscape. - int getPageItems() const; - // Total TOC items count int getTotalItems() const; public: explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::shared_ptr& epub, const std::string& epubPath, - const int currentSpineIndex) + BookPaginator& paginator, const int currentSpineIndex) : Activity("EpubReaderChapterSelection", renderer, mappedInput), - epub(epub), - epubPath(epubPath), + paginator(paginator), currentSpineIndex(currentSpineIndex) {} void onEnter() override; void onExit() override; diff --git a/src/activities/reader/EpubReaderUtils.h b/src/activities/reader/EpubReaderUtils.h index e9ac05b8..d93e467b 100644 --- a/src/activities/reader/EpubReaderUtils.h +++ b/src/activities/reader/EpubReaderUtils.h @@ -1,31 +1,77 @@ #pragma once -#include #include +#include +#include + #include "ProgressFile.h" namespace EpubReaderUtils { -// Persists reader progress for an EPUB to its cache directory. Returns true on success. -inline bool saveProgress(const Epub& epub, int spineIndex, int pageNumber, int pageCount) { - if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 || - pageCount > 0xFFFF) { - LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount); +// 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 +// a fraction of the chapter (KOReader remote sync, legacy migration), it is +// carried as `fractionQ16` with charStart == kNoCharStart and resolved +// against totalChars() once the chapter's page cache is open. +struct Progress { + uint16_t spineIndex = 0; + uint32_t charStart = 0; + uint32_t fractionQ16 = 0; // chapter fraction in Q16, used when charStart == kNoCharStart + bool valid = false; +}; + +constexpr uint32_t kNoCharStart = 0xFFFFFFFFu; + +// progress.bin v2: 'F','2', u16 spine, u32 charStart, u32 fractionQ16 (12 B, +// little-endian). Legacy v1 files (4 or 6 B: u16 spine, u16 page[, u16 +// pageCount]) migrate on read to a chapter fraction — a sentence-accurate +// landing that becomes exact the first time v2 progress is saved. +inline bool saveProgress(const std::string& cachePath, const uint16_t spineIndex, const uint32_t charStart, + const uint32_t fractionQ16 = 0) { + uint8_t data[12]; + data[0] = 'F'; + data[1] = '2'; + data[2] = spineIndex & 0xFF; + data[3] = (spineIndex >> 8) & 0xFF; + for (int i = 0; i < 4; ++i) data[4 + i] = (charStart >> (8 * i)) & 0xFF; + for (int i = 0; i < 4; ++i) data[8 + i] = (fractionQ16 >> (8 * i)) & 0xFF; + if (!ProgressFile::writeAtomic(cachePath, data, sizeof(data))) { return false; } - uint8_t data[6]; - data[0] = spineIndex & 0xFF; - data[1] = (spineIndex >> 8) & 0xFF; - data[2] = pageNumber & 0xFF; - data[3] = (pageNumber >> 8) & 0xFF; - data[4] = pageCount & 0xFF; - data[5] = (pageCount >> 8) & 0xFF; - if (!ProgressFile::writeAtomic(epub.getCachePath(), data, sizeof(data))) { - return false; - } - LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber); + LOG_DBG("ERS", "Progress saved: spine=%u char=%u", spineIndex, static_cast(charStart)); return true; } +inline Progress loadProgress(const std::string& cachePath) { + Progress p; + HalFile f; + if (!Storage.openFileForRead("ERS", cachePath + "/progress.bin", f)) { + return p; + } + uint8_t data[12]; + const int n = f.read(data, sizeof(data)); + if (n == 12 && data[0] == 'F' && data[1] == '2') { + p.spineIndex = data[2] | (data[3] << 8); + p.charStart = 0; + p.fractionQ16 = 0; + for (int i = 0; i < 4; ++i) p.charStart |= static_cast(data[4 + i]) << (8 * i); + for (int i = 0; i < 4; ++i) p.fractionQ16 |= static_cast(data[8 + i]) << (8 * i); + p.valid = true; + return p; + } + if (n == 4 || n == 6) { // legacy (spine, page[, pageCount]) — migrate to a fraction + p.spineIndex = data[0] | (data[1] << 8); + const uint16_t page = data[2] | (data[3] << 8); + const uint16_t pageCount = n == 6 ? (data[4] | (data[5] << 8)) : 0; + p.charStart = kNoCharStart; + p.fractionQ16 = + (pageCount > 0 && page != UINT16_MAX && page < pageCount) ? (static_cast(page) << 16) / pageCount : 0; + p.valid = true; + LOG_INF("ERS", "Migrated legacy progress: spine=%u page=%u/%u", p.spineIndex, page, pageCount); + } + return p; +} + } // namespace EpubReaderUtils diff --git a/src/activities/reader/FreeInkBookStorage.h b/src/activities/reader/FreeInkBookStorage.h index c948f787..e32e90ea 100644 --- a/src/activities/reader/FreeInkBookStorage.h +++ b/src/activities/reader/FreeInkBookStorage.h @@ -73,13 +73,11 @@ class SdCacheStorage : public freeink::book::CacheStorage { return true; } - bool write(const void* data, uint32_t len) override { - return write_.isOpen() && write_.write(data, len) == len; - } + bool write(const void* data, uint32_t len) override { return write_.isOpen() && write_.write(data, len) == len; } bool endWrite() override { if (!write_.isOpen()) return false; - write_.close(); // must close before rename (DESTRUCTOR_CLOSES_FILE covers scope exit only) + write_.close(); // must close before rename (DESTRUCTOR_CLOSES_FILE covers scope exit only) Storage.remove(commitPath_); // may not exist; rename below is the commit point if (!Storage.rename(path(kTempName), commitPath_)) { LOG_ERR("FIBCACHE", "commit rename failed: %s", commitPath_); diff --git a/src/activities/reader/FreeInkPageRenderer.cpp b/src/activities/reader/FreeInkPageRenderer.cpp new file mode 100644 index 00000000..4ba954aa --- /dev/null +++ b/src/activities/reader/FreeInkPageRenderer.cpp @@ -0,0 +1,239 @@ +#include "FreeInkPageRenderer.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "BookPaginator.h" +#include "CrossPointSettings.h" + +using freeink::book::Page; +using freeink::book::PageImage; +using freeink::book::PageTextRun; + +namespace { + +// Decode scratch for one image: inflate window + PNG/JPEG decoder state. +constexpr size_t kImageScratchSize = 72 * 1024; + +// 4x4 Bayer matrix (0..15) for quantizing the two mid-gray levels in BW mode. +constexpr uint8_t kBayer4[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}; + +EpdFontFamily::Style styleFor(const uint8_t flags) { + // Only the face-selecting bits: the engine pre-shifts sub/sup baselines and + // pre-sizes their runs, and underline is drawn as a rect below. + uint8_t s = EpdFontFamily::REGULAR; + if (flags & freeink::book::StyleBold) s |= EpdFontFamily::BOLD; + if (flags & freeink::book::StyleItalic) s |= EpdFontFamily::ITALIC; + return static_cast(s); +} + +// --- 2-bit image cache ----------------------------------------------------- +// +// File: u16 width, u16 height, then rows packed 4 pixels/byte, MSB-first, +// values in screen convention (0 = black .. 3 = white). + +void imageCachePath(const std::string& cacheDir, const PageImage& img, char* out, const size_t outCap) { + snprintf(out, outCap, "%s/i%08x_%ux%u.g2", cacheDir.c_str(), freeink::book::ZipCatalog::hashPath(img.href), img.width, + img.height); +} + +struct G2Writer { + HalFile file; + uint8_t rowBuf[512]; // packed row, up to 2048 px wide + uint16_t width = 0; + bool failed = false; + + static bool onRow(void* user, const uint16_t y, const uint8_t* gray, const uint16_t width) { + (void)y; + auto* self = static_cast(user); + if (self->failed || width != self->width || (width + 3u) / 4u > sizeof(self->rowBuf)) { + self->failed = true; + return false; + } + const uint16_t rowBytes = (width + 3) / 4; + memset(self->rowBuf, 0, rowBytes); + for (uint16_t i = 0; i < width; ++i) { + const uint8_t level = gray[i] >> 6; // 0=black .. 3=white + self->rowBuf[i >> 2] |= level << ((3 - (i & 3)) * 2); + } + if (self->file.write(self->rowBuf, rowBytes) != rowBytes) self->failed = true; + return !self->failed; + } +}; + +bool ensureImageCached(BookPaginator& paginator, const std::string& cacheDir, const PageImage& img, const char* path) { + if (Storage.exists(path)) return true; + (void)cacheDir; + + auto scratchBuf = makeUniqueNoThrow(kImageScratchSize); + if (!scratchBuf) { + LOG_ERR("FIBIMG", "OOM: image decode scratch (%u B)", static_cast(kImageScratchSize)); + return false; + } + freeink::book::Arena scratch(scratchBuf.get(), kImageScratchSize); + + char tmpPath[192]; + snprintf(tmpPath, sizeof(tmpPath), "%s.tmp", path); + G2Writer writer; + writer.width = img.width; + if (!Storage.openFileForWrite("FIBIMG", tmpPath, writer.file)) return false; + const uint8_t header[4] = {static_cast(img.width & 0xFF), static_cast(img.width >> 8), + static_cast(img.height & 0xFF), static_cast(img.height >> 8)}; + writer.failed = writer.file.write(header, sizeof(header)) != sizeof(header); + + const freeink::book::BookStatus st = freeink::book::ImageRenderer::render( + *paginator.bookSource(), paginator.book().zip(), img, scratch, &G2Writer::onRow, &writer); + writer.file.close(); // close before remove/rename below + if (st != freeink::book::BookStatus::Ok || writer.failed) { + LOG_ERR("FIBIMG", "Image decode failed (%d): %s", static_cast(st), img.href); + Storage.remove(tmpPath); + return false; + } + Storage.remove(path); // may not exist + if (!Storage.rename(tmpPath, path)) { + Storage.remove(tmpPath); + return false; + } + return true; +} + +void drawImageFromCache(const GfxRenderer& renderer, const char* path, const PageImage& img) { + HalFile f; + if (!Storage.openFileForRead("FIBIMG", path, f)) return; + uint8_t header[4]; + if (f.read(header, 4) != 4) return; + const uint16_t w = header[0] | (header[1] << 8); + const uint16_t h = header[2] | (header[3] << 8); + if (w != img.width || h != img.height || w == 0) return; + + const GfxRenderer::RenderMode mode = renderer.getRenderMode(); + uint8_t rowBuf[512]; + const uint16_t rowBytes = (w + 3) / 4; + if (rowBytes > sizeof(rowBuf)) return; + + for (uint16_t y = 0; y < h; ++y) { + if (f.read(rowBuf, rowBytes) != rowBytes) return; + const int screenY = img.y + y; + for (uint16_t x = 0; x < w; ++x) { + const uint8_t level = (rowBuf[x >> 2] >> ((3 - (x & 3)) * 2)) & 0x3; // 0=black..3=white + const int screenX = img.x + x; + if (mode == GfxRenderer::BW) { + // Solid black/white plus Bayer dithering for the two gray levels, so + // panels without a grayscale pass still show shading. + const bool black = level == 0 || (level < 3 && (level * 5) <= kBayer4[y & 3][x & 3]); + if (black) renderer.drawPixel(screenX, screenY, true); + } else if (mode == GfxRenderer::GRAYSCALE_MSB) { + // Same plane convention as 2-bit glyphs: mark grays with state=false. + if (level == 1 || level == 2) renderer.drawPixel(screenX, screenY, false); + } else if (mode == GfxRenderer::GRAYSCALE_LSB) { + if (level == 1) renderer.drawPixel(screenX, screenY, false); + } + } + } +} + +} // namespace + +namespace FreeInkPageRenderer { + +void drawPage(GfxRenderer& renderer, BookPaginator& paginator, const Page& page, const std::string& cacheDir) { + char textBuf[512]; + + for (uint16_t r = 0; r < page.runCount; ++r) { + const PageTextRun& run = page.runs[r]; + const uint16_t len = run.len < sizeof(textBuf) - 1 ? run.len : sizeof(textBuf) - 1; + memcpy(textBuf, run.text, len); + textBuf[len] = '\0'; + + const int fontId = paginator.fontIdForRunSize(run.sizePx); + const EpdFontFamily::Style style = styleFor(run.styleFlags); + // drawText's y is the line top; runs carry the baseline. NONE: the engine + // already produced visual order — never reorder here. + const int top = run.baselineY - renderer.getFontAscenderSize(fontId); + renderer.drawText(fontId, run.x, top, textBuf, true, style, BidiUtils::BidiBaseDir::NONE); + + if (run.styleFlags & freeink::book::StyleUnderline) { + const int width = renderer.getTextWidth(fontId, textBuf, style, BidiUtils::BidiBaseDir::NONE); + renderer.drawLine(run.x, run.baselineY + 2, run.x + width - 1, run.baselineY + 2, true); + } + } + + auto* fcm = renderer.getFontCacheManager(); + const bool scanning = fcm != nullptr && fcm->isScanning(); + if (scanning || SETTINGS.imageRendering == CrossPointSettings::IMAGES_SUPPRESS) return; + if (SETTINGS.imageRendering == CrossPointSettings::IMAGES_PLACEHOLDER) { + if (renderer.getRenderMode() == GfxRenderer::BW) { + for (uint16_t m = 0; m < page.imageCount; ++m) { + const PageImage& img = page.images[m]; + renderer.drawRect(img.x, img.y, img.width, img.height, true); + } + } + return; + } + + for (uint16_t m = 0; m < page.imageCount; ++m) { + const PageImage& img = page.images[m]; + char path[192]; + imageCachePath(cacheDir, img, path, sizeof(path)); + if (ensureImageCached(paginator, cacheDir, img, path)) { + drawImageFromCache(renderer, path, img); + } + } +} + +bool imageBoundingBox(const Page& page, int16_t* x, int16_t* y, int16_t* w, int16_t* h) { + if (page.imageCount == 0) return false; + int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN; + for (uint16_t m = 0; m < page.imageCount; ++m) { + const PageImage& img = page.images[m]; + minX = std::min(minX, img.x); + minY = std::min(minY, img.y); + maxX = std::max(maxX, img.x + img.width); + maxY = std::max(maxY, img.y + img.height); + } + *x = minX; + *y = minY; + *w = maxX - minX; + *h = maxY - minY; + return true; +} + +std::vector collectFootnotes(const Page& page) { + std::vector notes; + notes.reserve(page.linkCount); + for (uint16_t l = 0; l < page.linkCount; ++l) { + const freeink::book::PageLink& link = page.links[l]; + FootnoteEntry entry; + if (link.fragment != nullptr && link.fragment[0] != '\0') { + snprintf(entry.href, sizeof(entry.href), "%s#%s", link.target != nullptr ? link.target : "", link.fragment); + } else if (link.target != nullptr && link.target[0] != '\0') { + snprintf(entry.href, sizeof(entry.href), "%s", link.target); + } else { + continue; + } + snprintf(entry.number, sizeof(entry.number), "%u", static_cast(notes.size() + 1)); + notes.push_back(entry); + } + return notes; +} + +std::string pageText(const Page& page) { + std::string text; + size_t total = 0; + for (uint16_t r = 0; r < page.runCount; ++r) total += page.runs[r].len + 1; + text.reserve(total); + for (uint16_t r = 0; r < page.runCount; ++r) { + if (r > 0) text += ' '; // runs carry no separators (justified gaps are positional) + text.append(page.runs[r].text, page.runs[r].len); + } + return text; +} + +} // namespace FreeInkPageRenderer diff --git a/src/activities/reader/FreeInkPageRenderer.h b/src/activities/reader/FreeInkPageRenderer.h new file mode 100644 index 00000000..8d5cb8f7 --- /dev/null +++ b/src/activities/reader/FreeInkPageRenderer.h @@ -0,0 +1,45 @@ +#pragma once + +// Draws FreeInkBook page records through GfxRenderer — the migration's +// renderer contract: each run's UTF-8 text at (x, baselineY) with the font +// for (sizePx, styleFlags) using that font's own advances/kerning, a line +// under StyleUnderline runs, image rects, and link collection for the +// footnote UI. Text is never reordered, shaped, or spaced here: runs arrive +// in visual order with justification baked into their x positions +// (drawText is called with BidiBaseDir::NONE). +// +// Images decode once per (href, placement) into a 2-bit cache file beside +// the page caches, then every render pass — BW and both grayscale planes, +// including per-band strip re-renders — streams that file instead of +// re-decoding the PNG/JPEG. + +#include +#include + +#include +#include + +class GfxRenderer; +class BookPaginator; + +namespace FreeInkPageRenderer { + +// Draws the page's text runs and (unless SETTINGS disables images or the +// renderer is in a font-cache scan pass) its images, honoring the renderer's +// current render mode (BW / GRAYSCALE_LSB / GRAYSCALE_MSB). +void drawPage(GfxRenderer& renderer, BookPaginator& paginator, const freeink::book::Page& page, + const std::string& cacheDir); + +// True when the page places at least one image. +inline bool hasImages(const freeink::book::Page& page) { return page.imageCount > 0; } + +// Union of all image rects (for the blank-then-refresh e-ink technique). +bool imageBoundingBox(const freeink::book::Page& page, int16_t* x, int16_t* y, int16_t* w, int16_t* h); + +// The page's tappable links as footnote entries (href = target#fragment). +std::vector collectFootnotes(const freeink::book::Page& page); + +// Concatenated run text (QR display, bookmark summaries). +std::string pageText(const freeink::book::Page& page); + +} // namespace FreeInkPageRenderer diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index d4f7fef6..9762c8c2 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -70,7 +70,17 @@ 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); - if (!EpubReaderUtils::saveProgress(*epub, spineIndex, page, 0)) { + // 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)) { { RenderLock lock(*this); state = SYNC_FAILED; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 001e3340..3c7589ca 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -6,10 +6,8 @@ #include #include "CrossPointSettings.h" -#include "Epub.h" #include "EpubReaderActivity.h" #include "SdCardFontSystem.h" -#include "Txt.h" #include "TxtReaderActivity.h" #include "Xtc.h" #include "XtcReaderActivity.h" @@ -26,31 +24,6 @@ bool ReaderActivity::isTxtFile(const std::string& path) { bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } -std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { - if (!Storage.exists(path.c_str())) { - LOG_ERR("READER", "File does not exist: %s", path.c_str()); - return nullptr; - } - - auto epub = makeUniqueNoThrow(path, "/.crosspoint"); - if (!epub) { - LOG_ERR("READER", "Failed to allocate EPUB object"); - return nullptr; - } - // First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the - // indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at - // construction, so this check is valid before load(); a cached open loads in a blink -> no popup. - if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) { - GUI.drawPopup(renderer, tr(STR_INDEXING)); - } - if (epub->load(true, SETTINGS.embeddedStyle == 0)) { - return epub; - } - - LOG_ERR("READER", "Failed to load epub"); - return nullptr; -} - std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { if (!Storage.exists(path.c_str())) { LOG_ERR("READER", "File does not exist: %s", path.c_str()); @@ -70,35 +43,20 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } -std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { - if (!Storage.exists(path.c_str())) { - LOG_ERR("READER", "File does not exist: %s", path.c_str()); - return nullptr; - } - - auto txt = makeUniqueNoThrow(path, "/.crosspoint"); - if (!txt) { - LOG_ERR("READER", "Failed to allocate TXT object"); - return nullptr; - } - if (txt->load()) { - return txt; - } - - LOG_ERR("READER", "Failed to load TXT"); - return nullptr; -} - void ReaderActivity::goToLibrary(const std::string& fromBookPath) { // If coming from a book, start in that book's folder; otherwise start from root auto initialPath = fromBookPath.empty() ? "/" : FsHelpers::extractFolderPath(fromBookPath); activityManager.goToFileBrowser(std::move(initialPath)); } -void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { - const auto epubPath = epub->getPath(); - currentBookPath = epubPath; - activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(epub))); +void ReaderActivity::onGoToEpubReader(const std::string& path) { + if (!Storage.exists(path.c_str())) { + LOG_ERR("READER", "File does not exist: %s", path.c_str()); + onGoBack(); + return; + } + currentBookPath = path; + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); } void ReaderActivity::onGoToBmpViewer(const std::string& path) { @@ -111,10 +69,14 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(xtc))); } -void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { - const auto txtPath = txt->getPath(); - currentBookPath = txtPath; - activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); +void ReaderActivity::onGoToTxtReader(const std::string& path) { + if (!Storage.exists(path.c_str())) { + LOG_ERR("READER", "File does not exist: %s", path.c_str()); + onGoBack(); + return; + } + currentBookPath = path; + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); } void ReaderActivity::onEnter() { @@ -138,19 +100,9 @@ void ReaderActivity::onEnter() { } onGoToXtcReader(std::move(xtc)); } else if (isTxtFile(initialBookPath)) { - auto txt = loadTxt(initialBookPath); - if (!txt) { - onGoBack(); - return; - } - onGoToTxtReader(std::move(txt)); + onGoToTxtReader(initialBookPath); } else { - auto epub = loadEpub(initialBookPath); - if (!epub) { - onGoBack(); - return; - } - onGoToEpubReader(std::move(epub)); + onGoToEpubReader(initialBookPath); } } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 251030f3..7826bf4a 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -4,25 +4,20 @@ #include "activities/Activity.h" #include "activities/home/FileBrowserActivity.h" -class Epub; class Xtc; -class Txt; class ReaderActivity final : public Activity { std::string initialBookPath; std::string currentBookPath; // Track current book path for navigation - // Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer. - std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); - static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); static bool isBmpFile(const std::string& path); void goToLibrary(const std::string& fromBookPath = ""); - void onGoToEpubReader(std::unique_ptr epub); + void onGoToEpubReader(const std::string& path); void onGoToXtcReader(std::unique_ptr xtc); - void onGoToTxtReader(std::unique_ptr txt); + void onGoToTxtReader(const std::string& path); void onGoToBmpViewer(const std::string& path); void onGoBack(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0464c614..64f2af96 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -1,68 +1,75 @@ #include "TxtReaderActivity.h" -#include #include #include #include #include -#include -#include +#include #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "EpubReaderUtils.h" +#include "FreeInkPageRenderer.h" #include "MappedInputManager.h" -#include "ProgressFile.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" -namespace { -constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading -// Cache file magic and version -constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 3; // Increment when cache format changes -} // namespace - void TxtReaderActivity::onEnter() { Activity::onEnter(); - if (!txt) { + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + + // Same per-file cache dir convention the legacy Txt reader used. + cacheDir_ = "/.crosspoint/txt_" + std::to_string(std::hash{}(path_)); + Storage.ensureDirectoryExists("/.crosspoint"); + + if (!paginator.open(path_, cacheDir_, renderer, /*forcePlainText=*/true)) { + LOG_ERR("TRS", "Failed to open text file: %s", path_.c_str()); + activityManager.goToFullScreenMessage(tr(STR_PAGE_LOAD_ERROR), EpdFontFamily::BOLD); return; } - ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + const auto progress = EpubReaderUtils::loadProgress(cacheDir_); + // Only v2 progress applies: the legacy txt format stored a raw page number + // for a line-wrap pagination that no longer exists (reads back as a bogus + // spine index — a plain text file has exactly one chapter). + if (progress.valid && progress.spineIndex == 0 && progress.charStart != EpubReaderUtils::kNoCharStart) { + pendingCharStart = progress.charStart; + } - txt->setupCacheDir(); - - // Save current txt as last opened file and add to recent books - auto filePath = txt->getPath(); - auto fileName = filePath.substr(filePath.rfind('/') + 1); - APP_STATE.openEpubPath = filePath; + APP_STATE.openEpubPath = path_; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", ""); + RECENT_BOOKS.addBook(path_, title(), "", ""); - // Trigger first update requestUpdate(); } void TxtReaderActivity::onExit() { Activity::onExit(); - // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - pageOffsets.clear(); - currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); - txt.reset(); + paginator.close(); +} + +std::string TxtReaderActivity::title() const { + const size_t slash = path_.rfind('/'); + return path_.substr(slash == std::string::npos ? 0 : slash + 1); } void TxtReaderActivity::loop() { + if (!paginator.isOpen()) { + finish(); + return; + } + // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - activityManager.goToFileBrowser(txt ? txt->getPath() : ""); + activityManager.goToFileBrowser(path_); return; } @@ -82,7 +89,7 @@ void TxtReaderActivity::loop() { currentPage--; requestUpdate(); } else if (nextTriggered) { - if (currentPage < totalPages - 1) { + if (paginator.chapterReady() && currentPage + 1 < paginator.pageCount()) { currentPage++; requestUpdate(); } else { @@ -91,496 +98,122 @@ void TxtReaderActivity::loop() { } } -void TxtReaderActivity::initializeReader() { - if (initialized) { - return; - } - - // Store current settings for cache validation - cachedFontId = SETTINGS.getReaderFontId(); - cachedScreenMargin = SETTINGS.screenMargin; - cachedParagraphAlignment = SETTINGS.paragraphAlignment; - - // Calculate viewport dimensions - renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom, - &cachedOrientedMarginLeft); - cachedOrientedMarginTop += cachedScreenMargin; - cachedOrientedMarginLeft += cachedScreenMargin; - cachedOrientedMarginRight += cachedScreenMargin; - cachedOrientedMarginBottom += - std::max(cachedScreenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); - - viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; - const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; - const int lineHeight = renderer.getLineHeight(cachedFontId); - - linesPerPage = viewportHeight / lineHeight; - if (linesPerPage < 1) linesPerPage = 1; - - LOG_DBG("TRS", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage); - - // Try to load cached page index first - if (!loadPageIndexCache()) { - // Cache not found, build page index - buildPageIndex(); - // Save to cache for next time - savePageIndexCache(); - } - - // Load saved progress - loadProgress(); - - initialized = true; -} - -void TxtReaderActivity::buildPageIndex() { - pageOffsets.clear(); - pageOffsets.push_back(0); // First page starts at offset 0 - - size_t offset = 0; - const size_t fileSize = txt->getFileSize(); - - LOG_DBG("TRS", "Building page index for %zu bytes...", fileSize); - - GUI.drawPopup(renderer, tr(STR_INDEXING)); - - while (offset < fileSize) { - std::vector tempLines; - size_t nextOffset = offset; - - if (!loadPageAtOffset(offset, tempLines, nextOffset)) { - break; +bool TxtReaderActivity::ensureChapterAndPosition() { + const uint32_t gen = paginator.generation(); + if (!chapterOpen || openGeneration != gen) { + // Settings/orientation change: reanchor on the page being shown. + if (chapterOpen && !pendingCharStart.has_value()) { + pendingCharStart = lastCharStart; } + chapterOpen = false; + buildPopupShown = false; - if (nextOffset <= offset) { - // No progress made, avoid infinite loop - break; - } - - offset = nextOffset; - if (offset < fileSize) { - pageOffsets.push_back(offset); - } - - // Yield to other tasks periodically - if (pageOffsets.size() % 20 == 0) { - vTaskDelay(1); - } - } - - totalPages = pageOffsets.size(); - LOG_DBG("TRS", "Built page index: %d pages", totalPages); -} - -bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { - outLines.clear(); - const size_t fileSize = txt->getFileSize(); - - if (offset >= fileSize) { - return false; - } - - // Read a chunk from file - size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); - auto* buffer = static_cast(malloc(chunkSize + 1)); - if (!buffer) { - LOG_ERR("TRS", "Failed to allocate %zu bytes", chunkSize); - return false; - } - - if (!txt->readContent(buffer, offset, chunkSize)) { - free(buffer); - return false; - } - buffer[chunkSize] = '\0'; - - // Prime the SD card font's advance table with this chunk's codepoints. - // Without this, every getTextAdvanceX() call in the wrap loop below triggers - // on-demand glyph loads through the 8-slot overflow ring buffer, which - // thrashes for any text with more than 8 unique chars (i.e. all English), - // floods the heap with short-lived bitmap allocations, and eventually - // corrupts FreeRTOS state. The advance table persists across calls per - // font, so the cost amortizes to ~ASCII-size after the first chunk. - if (renderer.isSdCardFont(cachedFontId)) { - renderer.ensureSdCardFontReady(cachedFontId, reinterpret_cast(buffer), /*styleMask=*/0x01); - } - - // Parse lines from buffer - size_t pos = 0; - - while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { - // Find end of line - size_t lineEnd = pos; - while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { - lineEnd++; - } - - // Check if we have a complete line - bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); - - if (!lineComplete && static_cast(outLines.size()) > 0) { - // Incomplete line and we already have some lines, stop here - break; - } - - // Calculate the actual length of line content in the buffer (excluding newline) - size_t lineContentLen = lineEnd - pos; - - // Check for carriage return - bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); - size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; - - // Extract line content for display (without CR/LF) - std::string line(reinterpret_cast(buffer + pos), displayLen); - - // Track position within this source line (in bytes from pos) - size_t lineBytePos = 0; - - // Emit at least one visual line for each source line (including blank lines), - // then continue with wrapping when needed. - do { - if (line.empty()) { - outLines.emplace_back(); - break; + BookPaginator::BuildProgress progressCb; + progressCb.ctx = this; + progressCb.fn = [](void* ctx, uint32_t) { + auto* self = static_cast(ctx); + if (!self->buildPopupShown) { + GUI.drawPopup(self->renderer, tr(STR_INDEXING)); + self->pagesUntilFullRefresh = 1; + self->buildPopupShown = true; } + }; - int lineWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR); - - if (lineWidth <= viewportWidth) { - outLines.push_back(line); - lineBytePos = displayLen; // Consumed entire display content - line.clear(); - break; - } - - // Find break point - size_t breakPos = line.length(); - while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, line.substr(0, breakPos).c_str(), - EpdFontFamily::REGULAR) > viewportWidth) { - // Try to break at space - size_t spacePos = line.rfind(' ', breakPos - 1); - if (spacePos != std::string::npos && spacePos > 0) { - breakPos = spacePos; - } else { - // Break at character boundary for UTF-8 - breakPos--; - // Make sure we don't break in the middle of a UTF-8 sequence - while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { - breakPos--; - } - } - } - - if (breakPos == 0) { - breakPos = 1; - } - - outLines.push_back(line.substr(0, breakPos)); - - // Skip space at break point - size_t skipChars = breakPos; - if (breakPos < line.length() && line[breakPos] == ' ') { - skipChars++; - } - lineBytePos += skipChars; - line = line.substr(skipChars); - } while (!line.empty() && static_cast(outLines.size()) < linesPerPage); - - // Determine how much of the source buffer we consumed - if (line.empty()) { - // Fully consumed this source line, move past the newline - pos = lineEnd + 1; - } else { - // Partially consumed - page is full mid-line - // Move pos to where we stopped in the line (NOT past the line) - pos = pos + lineBytePos; - break; + const auto status = paginator.ensureChapter(0, progressCb); + if (status != freeink::book::BookStatus::Ok) { + LOG_ERR("TRS", "Pagination failed: %d", static_cast(status)); + return false; } + chapterOpen = true; + openGeneration = gen; } - // Ensure we make progress even if calculations go wrong - if (pos == 0 && !outLines.empty()) { - // Fallback: at minimum, consume something to avoid infinite loop - pos = 1; + if (pendingCharStart.has_value()) { + currentPage = paginator.pageForChar(*pendingCharStart); + pendingCharStart.reset(); } - - nextOffset = offset + pos; - - // Make sure we don't go past the file - if (nextOffset > fileSize) { - nextOffset = fileSize; + if (paginator.pageCount() > 0 && currentPage >= paginator.pageCount()) { + currentPage = paginator.pageCount() - 1; } - - free(buffer); - - return !outLines.empty(); + return true; } void TxtReaderActivity::render(RenderLock&&) { - if (!txt) { + if (!paginator.isOpen()) { return; } - // Initialize reader if not done - if (!initialized) { - initializeReader(); - } + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + orientedMarginRight += SETTINGS.screenMargin; + orientedMarginBottom += + std::max(SETTINGS.screenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); - if (pageOffsets.empty()) { + paginator.configureLayout(static_cast(renderer.getScreenWidth()), + static_cast(renderer.getScreenHeight()), static_cast(orientedMarginLeft), + static_cast(orientedMarginRight), static_cast(orientedMarginTop), + static_cast(orientedMarginBottom)); + + if (!ensureChapterAndPosition() || paginator.pageCount() == 0) { renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } - // Bounds check - if (currentPage < 0) currentPage = 0; - if (currentPage >= totalPages) currentPage = totalPages - 1; - - // Load current page content - size_t offset = pageOffsets[currentPage]; - size_t nextOffset; - currentPageLines.clear(); - loadPageAtOffset(offset, currentPageLines, nextOffset); + freeink::book::Page page{}; + if (paginator.readPage(currentPage, &page) != freeink::book::BookStatus::Ok) { + LOG_ERR("TRS", "Failed to read page %u - clearing cache", currentPage); + chapterOpen = false; + requestUpdate(); + return; + } + lastCharStart = page.charStart; renderer.clearScreen(); - renderPage(); + renderPage(page); - // Save progress - saveProgress(); + EpubReaderUtils::saveProgress(cacheDir_, 0, lastCharStart); } -void TxtReaderActivity::renderPage() { - const int lineHeight = renderer.getLineHeight(cachedFontId); - const int contentWidth = viewportWidth; - - // Render text lines with alignment - auto renderLines = [&]() { - int y = cachedOrientedMarginTop; - for (const auto& line : currentPageLines) { - if (!line.empty()) { - int x = cachedOrientedMarginLeft; - const bool lineIsRtl = BidiUtils::startsWithRtl(line.c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH); - uint8_t effectiveAlignment = cachedParagraphAlignment; - if (lineIsRtl && (effectiveAlignment == CrossPointSettings::LEFT_ALIGN || - effectiveAlignment == CrossPointSettings::JUSTIFIED)) { - effectiveAlignment = CrossPointSettings::RIGHT_ALIGN; - } - const int textWidth = renderer.getTextAdvanceX(cachedFontId, line.c_str(), EpdFontFamily::REGULAR); - - // Apply text alignment - switch (effectiveAlignment) { - case CrossPointSettings::LEFT_ALIGN: - default: - // x already set to left margin - break; - case CrossPointSettings::CENTER_ALIGN: { - x = cachedOrientedMarginLeft + (contentWidth - textWidth) / 2; - break; - } - case CrossPointSettings::RIGHT_ALIGN: { - x = cachedOrientedMarginLeft + contentWidth - textWidth; - break; - } - case CrossPointSettings::JUSTIFIED: - // For plain text, justified is treated as left-aligned - // (true justification would require word spacing adjustments) - break; - } - - renderer.drawText(cachedFontId, x, y, line.c_str()); - } - y += lineHeight; - } - }; - +void TxtReaderActivity::renderPage(const freeink::book::Page& page) { // Font prewarm: scan pass accumulates text, then prewarm, then real render auto* fcm = renderer.getFontCacheManager(); auto scope = fcm->createPrewarmScope(); - renderLines(); // scan pass — text accumulated, no drawing + FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); // scan pass scope.endScanAndPrewarm(); - // BW rendering - renderLines(); + FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); renderStatusBar(); ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); if (SETTINGS.textAntiAliasing) { - ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); + ReaderUtils::renderAntiAliased( + renderer, [this, &page]() { FreeInkPageRenderer::drawPage(renderer, paginator, page, cacheDir_); }); } - // scope destructor clears font cache via FontCacheManager } void TxtReaderActivity::renderStatusBar() const { - const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; - std::string title; + const uint32_t pageCount = paginator.pageCount(); + const float progress = pageCount > 0 ? (currentPage + 1) * 100.0f / pageCount : 0; + std::string barTitle; if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { - title = txt->getTitle(); + barTitle = title(); } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); -} - -void TxtReaderActivity::saveProgress() const { - uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = 0; - data[3] = 0; - if (!ProgressFile::writeAtomic(txt->getCachePath(), data, sizeof(data))) { - LOG_ERR("TRS", "Failed to save progress: page %d", currentPage); - } -} - -void TxtReaderActivity::loadProgress() { - HalFile f; - if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - if (f.read(data, 4) == 4) { - currentPage = data[0] + (data[1] << 8); - if (currentPage >= totalPages) { - currentPage = totalPages - 1; - } - if (currentPage < 0) { - currentPage = 0; - } - LOG_DBG("TRS", "Loaded progress: page %d/%d", currentPage, totalPages); - } - } -} - -bool TxtReaderActivity::loadPageIndexCache() { - // Cache file format (using serialization module): - // - uint32_t: magic "TXTI" - // - uint8_t: cache version - // - uint32_t: file size (to validate cache) - // - int32_t: viewport width - // - int32_t: lines per page - // - int32_t: font ID (to invalidate cache on font change) - // - int32_t: screen margin (to invalidate cache on margin change) - // - uint8_t: paragraph alignment (to invalidate cache on alignment change) - // - uint32_t: total pages count - // - N * uint32_t: page offsets - - std::string cachePath = txt->getCachePath() + "/index.bin"; - HalFile f; - if (!Storage.openFileForRead("TRS", cachePath, f)) { - LOG_DBG("TRS", "No page index cache found"); - return false; - } - - // Read and validate header using serialization module - uint32_t magic; - serialization::readPod(f, magic); - if (magic != CACHE_MAGIC) { - LOG_DBG("TRS", "Cache magic mismatch, rebuilding"); - return false; - } - - uint8_t version; - serialization::readPod(f, version); - if (version != CACHE_VERSION) { - LOG_DBG("TRS", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); - return false; - } - - uint32_t fileSize; - serialization::readPod(f, fileSize); - if (fileSize != txt->getFileSize()) { - LOG_DBG("TRS", "Cache file size mismatch, rebuilding"); - return false; - } - - int32_t cachedWidth; - serialization::readPod(f, cachedWidth); - if (cachedWidth != viewportWidth) { - LOG_DBG("TRS", "Cache viewport width mismatch, rebuilding"); - return false; - } - - int32_t cachedLines; - serialization::readPod(f, cachedLines); - if (cachedLines != linesPerPage) { - LOG_DBG("TRS", "Cache lines per page mismatch, rebuilding"); - return false; - } - - int32_t fontId; - serialization::readPod(f, fontId); - if (fontId != cachedFontId) { - LOG_DBG("TRS", "Cache font ID mismatch (%d != %d), rebuilding", fontId, cachedFontId); - return false; - } - - int32_t margin; - serialization::readPod(f, margin); - if (margin != cachedScreenMargin) { - LOG_DBG("TRS", "Cache screen margin mismatch, rebuilding"); - return false; - } - - uint8_t alignment; - serialization::readPod(f, alignment); - if (alignment != cachedParagraphAlignment) { - LOG_DBG("TRS", "Cache paragraph alignment mismatch, rebuilding"); - return false; - } - - uint32_t numPages; - serialization::readPod(f, numPages); - - // Read page offsets - pageOffsets.clear(); - pageOffsets.reserve(numPages); - - for (uint32_t i = 0; i < numPages; i++) { - uint32_t offset; - serialization::readPod(f, offset); - pageOffsets.push_back(offset); - } - - totalPages = pageOffsets.size(); - LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages); - return true; -} - -void TxtReaderActivity::savePageIndexCache() const { - std::string cachePath = txt->getCachePath() + "/index.bin"; - HalFile f; - if (!Storage.openFileForWrite("TRS", cachePath, f)) { - LOG_ERR("TRS", "Failed to save page index cache"); - return; - } - - // Write header using serialization module - serialization::writePod(f, CACHE_MAGIC); - serialization::writePod(f, CACHE_VERSION); - serialization::writePod(f, static_cast(txt->getFileSize())); - serialization::writePod(f, static_cast(viewportWidth)); - serialization::writePod(f, static_cast(linesPerPage)); - serialization::writePod(f, static_cast(cachedFontId)); - serialization::writePod(f, static_cast(cachedScreenMargin)); - serialization::writePod(f, cachedParagraphAlignment); - serialization::writePod(f, static_cast(pageOffsets.size())); - - // Write page offsets - for (size_t offset : pageOffsets) { - serialization::writePod(f, static_cast(offset)); - } - - LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); + GUI.drawStatusBar(renderer, progress, static_cast(currentPage) + 1, static_cast(pageCount), barTitle); } ScreenshotInfo TxtReaderActivity::getScreenshotInfo() const { ScreenshotInfo info; info.readerType = ScreenshotInfo::ReaderType::Txt; - if (txt) { - const std::string t = txt->getTitle(); - snprintf(info.title, sizeof(info.title), "%s", t.c_str()); - } - info.currentPage = currentPage + 1; - info.totalPages = totalPages; - info.progressPercent = totalPages > 0 ? static_cast((currentPage + 1) * 100.0f / totalPages + 0.5f) : 0; - if (info.progressPercent > 100) info.progressPercent = 100; + snprintf(info.title, sizeof(info.title), "%s", title().c_str()); + info.currentPage = static_cast(currentPage) + 1; + info.totalPages = static_cast(paginator.pageCount()); + info.progressPercent = + info.totalPages > 0 ? std::min(100, static_cast(info.currentPage * 100.0f / info.totalPages + 0.5f)) : 0; return info; } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index b5c8b0d9..7b015bfb 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -1,49 +1,36 @@ #pragma once -#include +#include +#include -#include - -#include "CrossPointSettings.h" +#include "BookPaginator.h" #include "activities/Activity.h" +// Plain-text (.txt / .md) reading UI over the FreeInkBook engine: the file is +// one chapter driven through ChapterLayout::layoutPlainText, so justification, +// hyphenation, page caching, and character-offset progress all work exactly +// as they do for EPUBs. class TxtReaderActivity final : public Activity { - std::unique_ptr txt; + std::string path_; + std::string cacheDir_; + BookPaginator paginator; - int currentPage = 0; - int totalPages = 1; + uint32_t currentPage = 0; + uint32_t lastCharStart = 0; + uint32_t openGeneration = 0; + bool chapterOpen = false; + bool buildPopupShown = false; + std::optional pendingCharStart; int pagesUntilFullRefresh = 0; - // Streaming text reader - stores file offsets for each page - std::vector pageOffsets; // File offset for start of each page - std::vector currentPageLines; - int linesPerPage = 0; - int viewportWidth = 0; - bool initialized = false; - - // Cached settings for cache validation (different fonts/margins require re-indexing) - int cachedFontId = 0; - uint8_t cachedScreenMargin = 0; - uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; - int cachedOrientedMarginTop = 0; - int cachedOrientedMarginRight = 0; - int cachedOrientedMarginBottom = 0; - int cachedOrientedMarginLeft = 0; - - void renderPage(); + bool ensureChapterAndPosition(); + void renderPage(const freeink::book::Page& page); void renderStatusBar() const; - - void initializeReader(); - bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); - void buildPageIndex(); - bool loadPageIndexCache(); - void savePageIndexCache() const; - void saveProgress() const; - void loadProgress(); + std::string title() const; public: - explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) - : Activity("TxtReader", renderer, mappedInput), txt(std::move(txt)) {} + explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) + : Activity("TxtReader", renderer, mappedInput), path_(std::move(path)) {} void onEnter() override; void onExit() override; void loop() override;