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;