#include "EpubReaderActivity.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "BookmarkEntry.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "DictionaryWordSelectActivity.h" #include "EpubReaderBookmarksActivity.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "MappedInputManager.h" #include "ProgressMapper.h" #include "QrDisplayActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/BookmarkUtil.h" #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) { return 0; } if (percent > 100) { return 100; } return 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) 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)}; } 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; } // 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; Storage.mkdir(READ_FOLDER); std::string dstPath = std::string(READ_FOLDER) + "/" + filename; if (!Storage.exists(dstPath.c_str())) { return dstPath; } const size_t dotPos = filename.rfind('.'); const std::string base = (dotPos != std::string::npos) ? filename.substr(0, dotPos) : filename; const std::string ext = (dotPos != std::string::npos) ? filename.substr(dotPos) : ""; int suffix = 2; do { dstPath = std::string(READ_FOLDER) + "/" + base + " (" + std::to_string(suffix) + ")" + ext; suffix++; } while (Storage.exists(dstPath.c_str()) && suffix < 100); return dstPath; } // 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()); if (!Storage.rename(srcPath.c_str(), dstPath.c_str())) { LOG_ERR("ERS", "Failed to move finished book to '/Read' folder"); 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)); 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; APP_STATE.saveToFile(); } } } // namespace void EpubReaderActivity::onEnter() { Activity::onEnter(); if (!epub) { return; } ImageBlock::clearSessionRenderFailures(); // 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(); 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); } } // 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); } } // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath()); loadCachedBookmarks(); // Trigger first update requestUpdate(); } void EpubReaderActivity::onExit() { Activity::onExit(); // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); // 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) { const SavedPosition& origin = savedPositions[0]; saveProgress(origin.spineIndex, origin.pageNumber, 0); } 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(); } } 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)); } }); } void EpubReaderActivity::openDictionaryWordSelect() { if (SETTINGS.dictionaryName[0] == '\0') { showDictionaryMessage = true; dictionaryMessageTime = millis(); requestUpdate(); return; } if (!section) return; auto page = section->loadPage(section->currentPage); if (!page) return; // Word geometry must match render(): viewable-area margins plus screen margin. int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; startActivityForResult(std::make_unique(renderer, mappedInput, std::move(page), orientedMarginLeft, orientedMarginTop), [this](const ActivityResult&) { requestUpdate(); }); } void EpubReaderActivity::loop() { if (!epub) { // Should never happen finish(); return; } // Lazily resume a partial's extension build once the reader nears its watermark. Far from // it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this // session, so reopening a partial deliberately does NOT start it (see the deferral in // render()); crossing this margin is the signal that the reader will actually need pages // past the watermark soon. Uses the last render's viewport so pagination matches the // partial being extended. if (section && !section->isBuilding() && section->isPartial() && !RenderLock::peek() && buildViewportWidth > 0 && !partialRebuildStartFailed && section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast(section->pageCount)) { RenderLock lock; if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, buildViewportWidth, buildViewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) { // Not fatal: the partial keeps serving its pages; crossing the watermark falls back to // the blocking extension in render(). Don't retry every tick. partialRebuildStartFailed = true; LOG_ERR("ERS", "Failed to start deferred partial extension build"); } else { LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage, section->pageCount); } } // 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. // While extending a partial (rebuild from a previous session), pageCount is pinned at the // partial's watermark until the build catches up, so the window check would wrongly read // "far enough ahead" and stall the build at 0 pages -- then the first turn past the // watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes. if (section && section->isBuilding() && !RenderLock::peek() && (section->isPartial() || 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(); } } } // 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. 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()); } 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()); 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. if (atEndOfBook) { pendingReadFolderMove = SETTINGS.moveFinishedToReadFolder && !isInReadFolder(epub->getPath()); } else { pendingReadFolderMove = false; } const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); if (automaticPageTurnActive) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back) || ReaderUtils::isTouchMenuGesture(mappedInput)) { automaticPageTurnActive = false; // updates chapter title space to indicate page turn disabled requestUpdate(); return; } if (!section) { requestUpdate(); return; } // Skips page turn if renderingMutex is busy if (RenderLock::peek()) { lastPageTurnTime = millis(); return; } if ((millis() - lastPageTurnTime) >= pageTurnDuration) { pageTurn(true); return; } } if (showBookmarkMessage && (millis() - bookmarkMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) { showBookmarkMessage = false; requestUpdate(); } if (showDictionaryMessage && (millis() - dictionaryMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) { showDictionaryMessage = false; 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. if (atEndOfBook && endOfBookOptions.menuActive() && !(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) { std::string openPath; switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) { case EndOfBookOptions::Action::OpenBook: activityManager.goToReader(openPath); return; case EndOfBookOptions::Action::GoHome: onGoHome(); return; case EndOfBookOptions::Action::LastPage: currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0); nextPageNumber = 0; pendingPageJump = std::numeric_limits::max(); requestUpdate(); return; case EndOfBookOptions::Action::Redraw: requestUpdate(); return; case EndOfBookOptions::Action::None: break; } } // Enter reader menu activity on short-press Confirm or a downward swipe from the top edge. 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. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; } else { openReaderMenu(); } } // Long-press Confirm runs the user-selected function (SETTINGS.longPressMenuFunction). 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; ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark bookmarkMessageTime = millis(); requestUpdate(); } 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 return; } } break; case CrossPointSettings::LP_MENU_DICTIONARY: // Hold ~0.4s starts dictionary word selection on the current page. if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showDictionaryMessage) { ignoreNextConfirmRelease = true; // Prevent menu open on the release that follows openDictionaryWordSelect(); return; } break; case CrossPointSettings::LP_MENU_DISABLED: default: break; } } // Short press Back restores position when viewing a footnote (takes priority over navigation) if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) { restoreSavedPosition(); return; } if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "", {this, [](void* ctx) { static_cast(ctx)->onGoHome(); }})) { 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) && !mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (footnoteDepth > 0) { restoreSavedPosition(); } else { if (currentPageFootnotes.size() == 1) { navigateToHref(currentPageFootnotes[0].href, true); } else if (currentPageFootnotes.size() > 1) { startActivityForResult( std::make_unique(renderer, mappedInput, currentPageFootnotes), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& footnoteResult = std::get(result.data); navigateToHref(footnoteResult.href, true); } requestUpdate(); }); } } return; } auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); prevTriggered = prevTriggered || touch.prev; nextTriggered = nextTriggered || touch.next; if (!prevTriggered && !nextTriggered) { 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()) { 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; } if (nextTriggered) { onGoHome(); } else { currentSpineIndex = epub->getSpineItemsCount() - 1; nextPageNumber = 0; pendingPageJump = std::numeric_limits::max(); requestUpdate(); } return; } const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime(); const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { return; } if (longPress && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP) { if (!nextTriggered && section && section->currentPage > 0) { section->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(); } requestUpdate(); return; } if (longPress && SETTINGS.longPressButtonBehavior == SETTINGS.ORIENTATION_CHANGE) { const uint8_t newOrientation = nextTriggered ? (SETTINGS.orientation - 1 + SETTINGS.ORIENTATION_COUNT) % SETTINGS.ORIENTATION_COUNT : (SETTINGS.orientation + 1) % SETTINGS.ORIENTATION_COUNT; applyOrientation(newOrientation); requestUpdate(); return; } // No current section, attempt to rerender the book if (!section) { requestUpdate(); return; } if (prevTriggered) { pageTurn(false); } else { pageTurn(true); } } // 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(); } } void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { auto progressChangeResultHandler = [this](const ActivityResult& result) { 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; } } }; 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), [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(); } }); break; } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& footnoteResult = std::get(result.data); navigateToHref(footnoteResult.href, true); } requestUpdate(); }); 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)); startActivityForResult( std::make_unique(renderer, mappedInput, initialPercent), [this](const ActivityResult& result) { if (!result.isCancelled) { jumpToPercent(std::get(result.data).percent); } }); break; } case EpubReaderMenuActivity::MenuAction::DICTIONARY: { openDictionaryWordSelect(); 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; } } // If no text or page loading failed, just close menu requestUpdate(); break; } case EpubReaderMenuActivity::MenuAction::GO_HOME: { onGoHome(); return; } 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"); } } } onGoHome(); return; } case EpubReaderMenuActivity::MenuAction::SCREENSHOT: { { RenderLock lock(*this); pendingScreenshot = true; } requestUpdate(); break; } case EpubReaderMenuActivity::MenuAction::SYNC: { launchKOReaderSync(); break; } case EpubReaderMenuActivity::MenuAction::BOOKMARKS: { startActivityForResult( std::make_unique(renderer, mappedInput, epub, epub->getPath()), progressChangeResultHandler); break; } case EpubReaderMenuActivity::MenuAction::TOGGLE_BOOKMARK: { addBookmark(); break; } } } 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; } } // 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(); // 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)) { 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()); { RenderLock lock(*this); if (section) { nextPageNumber = section->currentPage; } section.reset(); epub.reset(); } LOG_DBG("KOSync", "Epub 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)); 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(); } } void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; return; } 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(); } } 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++; } else { // We don't want to delete the section mid-render, so grab the semaphore { RenderLock lock(*this); nextPageNumber = 0; currentSpineIndex++; section.reset(); } } } else { if (section->currentPage > 0) { section->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(); } } } lastPageTurnTime = millis(); requestUpdate(); } // TODO: Failure handling void EpubReaderActivity::render(RenderLock&& lock) { if (!epub) { return; } const auto showPendingSyncSaveError = [this]() { if (!pendingSyncSaveError) return; pendingSyncSaveError = false; GUI.drawPopup(renderer, tr(STR_SAVE_PROGRESS_FAILED)); }; // A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the // "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging. // clearScreen first so the error popup doesn't overlay the stale "Indexing" popup. const auto showBuildError = [this]() { renderer.clearScreen(); GUI.drawPopup(renderer, tr(STR_INDEX_FAILED)); automaticPageTurnActive = false; }; // edge case handling for sub-zero spine index if (currentSpineIndex < 0) { currentSpineIndex = 0; } // based bounds of book, show end of book screen if (currentSpineIndex > epub->getSpineItemsCount()) { currentSpineIndex = epub->getSpineItemsCount(); } // 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()); renderer.clearScreen(); endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); automaticPageTurnActive = false; showPendingSyncSaveError(); return; } // Apply screen viewable areas and additional padding int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; 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 += std::max(SETTINGS.screenMargin, static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin)); } else { orientedMarginBottom += std::max(SETTINGS.screenMargin, statusBarHeight); } const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; // Capture for loop()'s lazy partial-extension start (must match this render's layout params). buildViewportWidth = viewportWidth; buildViewportHeight = viewportHeight; 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)); // Fresh section, fresh chance: a failed lazy extension start in a previous // section must not suppress watermark-triggered rebuilds for this one. partialRebuildStartFailed = false; // 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; // No popup redraws while the framebuffer is lent to the build below; // the panel holds the popup displayed above (e-ink is persistent). const auto popupFn = [this]() { if (renderer.hasFrameBuffer()) GUI.drawPopup(renderer, tr(STR_INDEXING)); }; // Lend the framebuffer's 48 KB to the blocking full build; restored // (white) at scope exit, and the page render below redraws everything. GfxRenderer::FrameBufferLoan loan(renderer); 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(); loan.end(); // restore before anything draws showBuildError(); return; } loan.end(); } 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 bool anchorJump = !pendingAnchor.empty(); // Landing well inside a partial: the page (or anchor, via the on-disk map) is already // servable, so don't restart the extension build now -- it re-lays out the WHOLE chapter // from page 0 (minutes of background CPU + SD writes on a giant spine), pure waste when // the reader never nears the watermark this session. loop() starts it lazily once the // reader is within PARTIAL_REBUILD_START_MARGIN pages of the watermark. if (section->isPartial() && (anchorJump ? section->getPageForAnchor(pendingAnchor).has_value() : target + PARTIAL_REBUILD_START_MARGIN < static_cast(section->pageCount))) { LOG_DBG("ERS", "Partial covers target %d of %d; deferring extension build", target, section->pageCount); } else { 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(); 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; } // Lend the framebuffer's 48 KB to the blocking pre-render burst // (startBuild inflates the whole spine HTML — the memory peak). The // background buildSomeMore chunks in loop() do NOT get the loan: they // deliberately interleave with page renders. Restored before render. GfxRenderer::FrameBufferLoan loan(renderer); 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(); loan.end(); // restore before anything draws (showBuildError renders a popup) showBuildError(); 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(); loan.end(); // restore before anything draws (showBuildError renders a popup) showBuildError(); return; } } loan.end(); } } } 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; } } // 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. // // Crossing a partial's watermark before the extension rebuild has caught up means a // synchronous wait spanning the remaining prefix re-layout -- potentially tens of // seconds on a giant spine. Show the indexing popup so it isn't a silent freeze // (the page that replaces it takes the HALF ghost-cleanup path). Ordinary window // catch-ups on a non-partial build are a page or two and stay popup-free. if (section->isPartial() && section->currentPage >= static_cast(section->pageCount)) { GUI.drawPopup(renderer, tr(STR_INDEXING)); pagesUntilFullRefresh = 1; } 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(); showBuildError(); 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(); showBuildError(); 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(); showBuildError(); 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) { LOG_DBG("ERS", "No pages to render"); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); renderStatusBar(); renderer.displayBuffer(); automaticPageTurnActive = false; showPendingSyncSaveError(); 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(); automaticPageTurnActive = false; showPendingSyncSaveError(); return; } 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"); automaticPageTurnActive = false; // Retrying rebuilds a transiently corrupt section and usually recovers, but a page that keeps // failing would loop forever on a blank screen, so bound the retries before giving up. const bool giveUp = ++pageLoadRetryCount > MAX_PAGE_LOAD_RETRIES; // 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(); if (giveUp) { LOG_ERR("ERS", "Page load retry limit reached, aborting"); pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); renderer.displayBuffer(); showPendingSyncSaveError(); return; } requestUpdate(); // Try again after clearing cache showPendingSyncSaveError(); return; } pageLoadRetryCount = 0; // Reset the retry counter once a page loads cleanly // 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); } // Only persist when the position actually changed. render() also runs on menu, // bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes. // Every real page turn changes currentPage, so progress durability is unaffected. if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage || section->pageCount != lastSavedPageCount) { if (saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages())) { lastSavedSpineIndex = currentSpineIndex; lastSavedPage = section->currentPage; lastSavedPageCount = section->estimatedTotalPages(); } } showPendingSyncSaveError(); if (pendingScreenshot) { pendingScreenshot = false; ScreenshotUtil::takeScreenshot(renderer); } if (showBookmarkMessage) { GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED)); } if (showDictionaryMessage) { GUI.drawPopup(renderer, tr(STR_DICT_NO_DICT_SET)); } } 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) { 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 scope.endScanAndPrewarm(); const auto tPrewarm = millis(); const bool pageHasImages = page->hasImages(); const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode(); const bool needsTextGrayscale = SETTINGS.textAntiAliasing; const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages; const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale(); // Whole-plane buffering only pays when the BW refresh genuinely runs async // underneath it; on blocking panels it would just spend ~50 KB for the // identical serial timing. const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh(); auto renderGrayscalePass = [&]() { if (needsTextGrayscale) { page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); } else { page->renderImages(renderer, fontId, orientedMarginLeft, orientedMarginTop); } }; if (pageHasImagesNeedingDecode) { page->renderWithImagePlaceholders(renderer, fontId, orientedMarginLeft, orientedMarginTop); renderStatusBar(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); renderer.clearScreen(); } page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); 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) int16_t imgX, imgY, imgW, imgH; if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, 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); 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. pagesUntilFullRefresh = 1; } else { // Deferred when a tiled grayscale pass follows: the plane rendering below // then overlaps the panel's refresh time instead of following it. ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh); } const auto tDisplay = millis(); // Tiled grayscale: render each plane band-by-band, 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. When the BW refresh above went out async, the plane // rendering below overlaps the panel's refresh time; only the controller // RAM writes wait for BUSY. if (tiledGrayscale) { constexpr int STRIP_ROWS = 80; const int gh = renderer.getDisplayHeight(); const int gwBytes = renderer.getDisplayWidthBytes(); const size_t planeBytes = static_cast(gwBytes) * gh; // Render one plane band-by-band into a whole-plane buffer without touching // the controller, so it can run while the refresh is still in flight. auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) { renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB); for (int y = 0; y < gh; y += STRIP_ROWS) { const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; renderer.beginStripTarget(buf + static_cast(y) * gwBytes, y, rows); renderer.clearScreen(0x00); renderGrayscalePass(); renderer.endStripTarget(); } }; // Tiered on heap pressure: two plane buffers hide both plane renders // inside the refresh wait; one hides the LSB render (its buffer is reused // for MSB after streaming); none falls back to the strip-scratch flow with // no overlap. The MSB buffer is only attempted when it leaves ~60 KB free // so the pass never starves concurrent allocations. Blocking panels skip // the buffers entirely (nothing to overlap). auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow(planeBytes) : nullptr; auto msbPlaneBuf = (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow(planeBytes) : nullptr; if (lsbPlaneBuf) { renderPlaneToBuffer(true, lsbPlaneBuf.get()); if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get()); const auto tGrayRender = millis(); renderer.waitRefreshComplete(); const auto tWait = millis(); renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh); if (msbPlaneBuf) { renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh); } else { renderPlaneToBuffer(false, lsbPlaneBuf.get()); renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh); } const auto tGrayWrite = 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 tEnd = millis(); LOG_DBG("ERS", "Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums " "wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)", tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender, tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1); } else { // Per-strip scratch tier: blocking panels and the OOM fallback. The // strip writes below need the panel idle, so wait out any pending async // refresh first (no-op on blocking panels). auto scratch = makeUniqueNoThrow(static_cast(gwBytes) * STRIP_ROWS); renderer.waitRefreshComplete(); 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; renderer.beginStripTarget(scratch.get(), y, rows); renderer.clearScreen(0x00); renderGrayscalePass(); 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; renderer.beginStripTarget(scratch.get(), y, rows); renderer.clearScreen(0x00); renderGrayscalePass(); 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); } } } 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); } } } 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; 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); if (tocIndex != -1) { const auto tocItem = epub->getTocItem(tocIndex); title = tocItem.title; } } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { title = epub->getTitle(); } GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked, section->isBuilding()); } 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}; footnoteDepth++; LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage); } // Extract fragment anchor (e.g. "#note1" or "chapter2.xhtml#note1") std::string anchor; const auto hashPos = hrefStr.find('#'); if (hashPos != std::string::npos && hashPos + 1 < hrefStr.size()) { anchor = hrefStr.substr(hashPos + 1); } // 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); } if (targetSpineIndex < 0) { LOG_DBG("ERS", "Could not resolve href: %s", hrefStr.c_str()); if (savePosition && footnoteDepth > 0) footnoteDepth--; // undo push return; } { RenderLock lock(*this); pendingAnchor = std::move(anchor); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; section.reset(); } requestUpdate(); LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, hrefStr.c_str()); } 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(); } requestUpdate(); } void EpubReaderActivity::loadCachedBookmarks() { cachedBookmarks.clear(); if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) { cachedBookmarks.reserve(initialBookmarkCacheCapacity); } if (!epub) { currentPageBookmarked = false; return; } const std::string bmPath = BookmarkUtil::getBookmarkPath(epub->getPath()); if (Storage.exists(bmPath.c_str())) { String json = Storage.readFile(bmPath.c_str()); if (!json.isEmpty()) { JsonSettingsIO::loadBookmarks(cachedBookmarks, json.c_str()); } } updateBookmarkFlag(); } void EpubReaderActivity::addBookmark() { if (!section || !epub) { 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; } SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition()); const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount); 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); }), 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.computedSpineIndex = currentSpineIndex; entry.computedChapterPageCount = pageCount; entry.computedChapterProgress = currentPage; cachedBookmarks.insert(cachedBookmarks.begin(), entry); bookmarkRemoved = false; currentPageBookmarked = true; } const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath()); const std::string bookmarksDir = BookmarkUtil::getBookmarksDir(); Storage.mkdir(bookmarksDir.c_str()); const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str()); if (!ok) { LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str()); } requestUpdate(); } void EpubReaderActivity::updateBookmarkFlag() { if (!section || !epub || cachedBookmarks.empty()) { currentPageBookmarked = false; return; } const int pageCount = section->estimatedTotalPages(); const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount); currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) { return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange); }); } 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; } } 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; }