#include "EpubReaderActivity.h" #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "DictionaryWordSelectActivity.h" #include "EpubReaderBookmarkSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "LookedUpWordsActivity.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/BookmarkStore.h" #include "util/Dictionary.h" #include "util/ScreenshotUtil.h" namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; // pages per minute, first item is 1 to prevent division by zero if accessed const std::vector PAGE_TURN_LABELS = {1, 1, 3, 6, 12}; int clampPercent(int percent) { if (percent < 0) { return 0; } if (percent > 100) { return 100; } return percent; } // Apply the logical reader orientation to the renderer. // This centralizes orientation mapping so we don't duplicate switch logic elsewhere. void applyReaderOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: renderer.setOrientation(GfxRenderer::Orientation::Portrait); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); break; case CrossPointSettings::ORIENTATION::INVERTED: renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); break; default: break; } } } // 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. applyReaderOrientation(renderer, SETTINGS.orientation); epub->setupCacheDir(); FsFile 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); cachedSpineIndex = currentSpineIndex; LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber); } if (dataSize == 6) { cachedChapterTotalPageCount = data[4] + (data[5] << 8); } f.close(); } // 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()); // 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(); section.reset(); epub.reset(); } void EpubReaderActivity::loop() { if (!epub) { // Should never happen finish(); return; } if (automaticPageTurnActive) { 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(); return; } // Skips page turn if renderingMutex is busy if (RenderLock::peek()) { lastPageTurnTime = millis(); return; } if ((millis() - lastPageTurnTime) >= pageTurnDuration) { pageTurn(true); return; } } // Enter reader menu activity. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const int currentPage = section ? section->currentPage + 1 : 0; const int totalPages = section ? section->pageCount : 0; float bookProgress = 0.0f; if (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 bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); const bool isBookmarked = section ? BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage) : false; startActivityForResult(std::make_unique( renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, !currentPageFootnotes.empty(), isBookmarked, SETTINGS.fontSize), [this](const ActivityResult& result) { // Always apply orientation and font size even if the menu was cancelled const auto& menu = std::get(result.data); applyOrientation(menu.orientation); applyFontSize(menu.fontSize); toggleAutoPageTurn(menu.pageTurnOption); if (!result.isCancelled) { onReaderMenuConfirm(static_cast(menu.action)); } }); } // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { activityManager.goToFileBrowser(epub ? epub->getPath() : ""); return; } // Short press BACK goes directly to home (or restores position if viewing footnote) if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { if (footnoteDepth > 0) { restoreSavedPosition(); return; } onGoHome(); return; } // When long-press chapter skip is disabled, turn pages on press instead of release. const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip; const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) || mappedInput.wasPressed(MappedInputManager::Button::Left)) : (mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left)); const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && mappedInput.wasReleased(MappedInputManager::Button::Power); const bool nextTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn || mappedInput.wasPressed(MappedInputManager::Button::Right)) : (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn || mappedInput.wasReleased(MappedInputManager::Button::Right)); if (!prevTriggered && !nextTriggered) { return; } // any botton press when at end of the book goes back to the last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { currentSpineIndex = epub->getSpineItemsCount() - 1; nextPageNumber = UINT16_MAX; requestUpdate(); return; } const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; if (skipChapter) { lastPageTurnTime = millis(); // We don't want to delete the section mid-render, so grab the semaphore { RenderLock lock(*this); nextPageNumber = 0; currentSpineIndex = nextTriggered ? currentSpineIndex + 1 : currentSpineIndex - 1; section.reset(); } 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) { switch (action) { case EpubReaderMenuActivity::MenuAction::TABLE_OF_CONTENTS: 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 && currentSpineIndex != std::get(result.data).spineIndex) { RenderLock lock(*this); currentSpineIndex = std::get(result.data).spineIndex; 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::DISPLAY_QR: { if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { auto p = section->loadPageFromSectionFile(); if (p) { std::string fullText; for (const auto& el : p->elements) { if (el->getTag() == TAG_PageLine) { const auto& line = static_cast(*el); if (line.getBlock()) { const auto& words = line.getBlock()->getWords(); for (const auto& w : words) { if (!fullText.empty()) fullText += " "; fullText += w; } } } } 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(); saveProgress(backupSpine, backupPage, backupPageCount); } } onGoHome(); return; } case EpubReaderMenuActivity::MenuAction::SCREENSHOT: { { RenderLock lock(*this); pendingScreenshot = true; } requestUpdate(); break; } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; startActivityForResult( std::make_unique(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& sync = std::get(result.data); if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { RenderLock lock(*this); currentSpineIndex = sync.spineIndex; nextPageNumber = sync.page; section.reset(); } } }); } break; } case EpubReaderMenuActivity::MenuAction::CLOSE_BOOK: onGoHome(); return; case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: { if (section && BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage, "")) { requestUpdate(); } break; } case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: { if (section && BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) { requestUpdate(); } break; } case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: { auto bookmarks = BookmarkStore::load(epub->getCachePath()); startActivityForResult( std::make_unique(renderer, mappedInput, epub, std::move(bookmarks), epub->getCachePath()), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& sync = std::get(result.data); if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { RenderLock lock(*this); currentSpineIndex = sync.spineIndex; nextPageNumber = sync.page; section.reset(); } } }); break; } case EpubReaderMenuActivity::MenuAction::LOOKUP_WORD: { if (!section || !Dictionary::cacheExists()) { requestUpdate(); break; } auto p = section->loadPageFromSectionFile(); if (!p) { requestUpdate(); break; } 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(p), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, epub->getCachePath(), SETTINGS.orientation, ""), [this](const ActivityResult&) { requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::LOOKUP_HISTORY: { if (!Dictionary::cacheExists()) { requestUpdate(); break; } startActivityForResult( std::make_unique(renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation), [this](const ActivityResult&) { requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: { if (Dictionary::cacheExists()) { Dictionary::deleteCache(); } requestUpdate(); break; } } } 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. applyReaderOrientation(renderer, SETTINGS.orientation); // Reset section to force re-layout in the new orientation. section.reset(); } } void EpubReaderActivity::applyFontSize(const uint8_t fontSize) { if (fontSize >= CrossPointSettings::FONT_SIZE_COUNT || SETTINGS.fontSize == fontSize) { return; } { RenderLock lock(*this); if (section) { cachedSpineIndex = currentSpineIndex; cachedChapterTotalPageCount = section->pageCount; nextPageNumber = section->currentPage; } SETTINGS.fontSize = fontSize; SETTINGS.saveToFile(); section.reset(); } } void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= PAGE_TURN_LABELS.size()) { automaticPageTurnActive = false; return; } lastPageTurnTime = millis(); // calculates page turn duration by dividing by number of pages pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_LABELS[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) { if (section->currentPage < section->pageCount - 1) { 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 = UINT16_MAX; currentSpineIndex--; section.reset(); } } } lastPageTurnTime = millis(); requestUpdate(); } // TODO: Failure handling void EpubReaderActivity::render(RenderLock&& lock) { if (!epub) { return; } // 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()) { renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); renderer.displayBuffer(); automaticPageTurnActive = false; 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); } 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)); const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { LOG_DBG("ERS", "Cache not found, building..."); 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, popupFn)) { LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); return; } } else { LOG_DBG("ERS", "Cache found, skipping build..."); } if (nextPageNumber == UINT16_MAX) { section->currentPage = section->pageCount - 1; } else { section->currentPage = nextPageNumber; } if (!pendingAnchor.empty()) { if (const auto page = section->getPageForAnchor(pendingAnchor)) { 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(); } // handles changes in reader settings and reset to approximate position based on cached progress if (cachedChapterTotalPageCount > 0) { // only goes to relative position if spine index matches cached value if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); int newPage = static_cast(progress * section->pageCount); section->currentPage = newPage; } cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again } 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; } } 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; 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; return; } { auto p = section->loadPageFromSectionFile(); if (!p) { LOG_ERR("ERS", "Failed to load page from SD - clearing section cache"); 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; return; } // 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); renderer.clearFontCache(); } saveProgress(currentSpineIndex, section->currentPage, section->pageCount); if (pendingScreenshot) { pendingScreenshot = false; ScreenshotUtil::takeScreenshot(renderer); } } void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { FsFile f; if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[6]; data[0] = currentSpineIndex & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF; data[2] = currentPage & 0xFF; data[3] = (currentPage >> 8) & 0xFF; data[4] = pageCount & 0xFF; data[5] = (pageCount >> 8) & 0xFF; f.write(data, 6); f.close(); LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d", spineIndex, currentPage); } else { LOG_ERR("ERS", "Could not save progress!"); } } void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { // Force special handling for pages with images when anti-aliasing is on bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing; page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(); if (imagePageWithAA) { // 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 page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } // Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence } else if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); pagesUntilFullRefresh--; } // Save bw buffer to reset buffer state after grayscale data sync renderer.storeBwBuffer(); // grayscale rendering // TODO: Only do this if font supports it if (SETTINGS.textAntiAliasing) { renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderer.copyGrayscaleLsbBuffers(); // Render and copy to MSB buffer renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderer.copyGrayscaleMsbBuffers(); // display grayscale part renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); } // restore the bw data renderer.restoreBwBuffer(); } void EpubReaderActivity::renderStatusBar() const { // Calculate progress in book const int currentPage = section->currentPage + 1; const float pageCount = section->pageCount; 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); } 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(); }