Files
Crosspoint/src/activities/reader/EpubReaderActivity.cpp
T
Vadim KaushanandClaude Sonnet 4.6 213972badc fix: navigate to TOC anchor when selecting sub-chapters (#1981)
Chapter selection previously only used the spine index, causing
navigation to always land on page 0 of the spine item. Sub-chapters that
share a spine file but differ by anchor (e.g. `chapter.xhtml#sec2`) were
silently ignored. Now the TOC anchor is passed through `ChapterResult`
and applied via the existing `pendingAnchor` mechanism.

## Summary

* This PR implements navigation to sub-chapters which didn't work
correctly previously. If a sub-chapter of the current top-level chapter
was selected, nothing happened. If a sub-chapter of another top-level
chapter was selected, reader switched to the beginning of the top-level
chapter.
* In addition, chapters now always start from a new page. This fixes
anchor to page calculation for the cases when the actual chapter content
doesn't fit on the page where the corresponding ToC anchor was found.

## Additional Context

* I might misuse `pendingAnchor` here which was previously used for
footnote navigation, please double check. I'm open to suggestions for
improvements.
* Note that the chapter selected by default when
`EpubReaderChapterSelectionActivity` opens is still wrong. I'm going to
fix this separately. This PR addresses only navigation to the selected
chapter.
* I tested this PR on my X4 and verified that navigation to a different
sub-chapter works correctly, both inside and outside the current spine.
* Some of the changes were borrowed from
https://github.com/crosspoint-reader/crosspoint-reader/pull/1455
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:14:32 -05:00

1139 lines
44 KiB
C++

#include "EpubReaderActivity.h"
#include <Epub/Page.h>
#include <Epub/blocks/TextBlock.h>
#include <FontCacheManager.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <Memory.h>
#include <esp_system.h>
#include <functional>
#include <iterator>
#include <limits>
#include "CrossPointSettings.h"
#include "CrossPointState.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/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};
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 "<READ_FOLDER>/"). 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] == '/';
}
// 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<std::string>{}(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;
}
// 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());
// 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();
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::loop() {
if (!epub) {
// Should never happen
finish();
return;
}
// 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;
}
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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
return;
}
// Short press BACK goes directly to home (or restores position if viewing footnote)
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
if (footnoteDepth > 0) {
restoreSavedPosition();
return;
}
onGoHome();
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
// At end of the book, forward button goes home and back button returns to last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
if (nextTriggered) {
onGoHome();
} else {
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = 0;
pendingPageJump = std::numeric_limits<uint16_t>::max();
requestUpdate();
}
return;
}
const bool longPress = !fromTilt && mappedInput.getHeldTime() > 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) {
// 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;
}
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<size_t>(percent) + (bookSize % 100) * static_cast<size_t>(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<float>(targetSize - prevCumulative) / static_cast<float>(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::SELECT_CHAPTER: {
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
startActivityForResult(
std::make_unique<EpubReaderChapterSelectionActivity>(renderer, mappedInput, epub, path, spineIdx),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& chapterResult = std::get<ChapterResult>(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<EpubReaderFootnotesActivity>(renderer, mappedInput, currentPageFootnotes),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& footnoteResult = std::get<FootnoteResult>(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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int initialPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(
std::make_unique<EpubReaderPercentSelectionActivity>(renderer, mappedInput, initialPercent),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
jumpToPercent(std::get<PercentResult>(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<const PageLine&>(*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<QrDisplayActivity>(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: {
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(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 = {currentSpineIndex, currentPage, totalPages};
if (paragraphIndex.has_value()) {
localPos.paragraphIndex = *paragraphIndex;
localPos.hasParagraphIndex = true;
}
KOReaderPosition localKoPos = ProgressMapper::toKOReader(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;
}
// 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<KOReaderSyncActivity>(
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
std::move(localChapterName), paragraphIndex));
}
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.
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) {
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 = 0;
pendingPageJump = std::numeric_limits<uint16_t>::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));
};
// 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;
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<uint8_t>(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;
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<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_DBG("ERS", "Cache not found, building...");
GUI.drawPopup(renderer, tr(STR_INDEXING));
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showPendingSyncSaveError();
return;
}
} else {
LOG_DBG("ERS", "Cache found, skipping build...");
}
if (pendingPageJump.has_value()) {
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
section->currentPage = section->pageCount - 1;
} else {
section->currentPage = *pendingPageJump;
}
pendingPageJump.reset();
} else {
section->currentPage = nextPageNumber;
if (section->currentPage < 0) {
section->currentPage = 0;
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
section->currentPage = section->pageCount - 1;
}
}
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<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
int newPage = static_cast<int>(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<int>(pendingSpineProgress * static_cast<float>(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;
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;
}
{
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;
showPendingSyncSaveError();
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);
}
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
showPendingSyncSaveError();
if (pendingScreenshot) {
pendingScreenshot = false;
ScreenshotUtil::takeScreenshot(renderer);
}
}
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
if (!epub || !section || section->pageCount < 2) {
return;
}
// Build the next chapter cache while the penultimate page is on screen.
if (section->currentPage != section->pageCount - 2) {
return;
}
const int nextSpineIndex = currentSpineIndex + 1;
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
return;
}
Section nextSection(epub, nextSpineIndex, renderer);
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
return;
}
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
}
}
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount);
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
const auto t0 = millis();
// Font prewarm: scan pass accumulates text, then prewarm, then real render
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
scope.endScanAndPrewarm();
const auto tPrewarm = millis();
// 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();
const auto tBwRender = millis();
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
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
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 {
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
}
const auto tDisplay = millis();
// Tiled grayscale: render each plane band-by-band into a small scratch and
// stream straight to the controller, leaving the BW framebuffer intact so no
// full-frame storeBwBuffer is needed; controller RAM is re-synced from the
// live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
// cost stays close to one render. Both text (drawPixel) and images
// (DirectPixelWriter) honor the active strip target.
if (SETTINGS.textAntiAliasing && renderer.supportsStripGrayscale()) {
constexpr int STRIP_ROWS = 80;
const int gh = renderer.getDisplayHeight();
const int gwBytes = renderer.getDisplayWidthBytes();
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
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);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
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);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
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 (SETTINGS.textAntiAliasing) {
// Save the BW frame before the grayscale passes overwrite it, restore
// after. Only needed when grayscale actually renders.
renderer.storeBwBuffer();
const auto tBwStore = millis();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleLsbBuffers();
const auto tGrayLsb = millis();
// Render and copy to MSB buffer
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
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 anti-aliasing: 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
const int currentPage = section->currentPage + 1;
const float pageCount = section->pageCount;
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(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();
}
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->pageCount;
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
info.progressPercent = pct;
}
}
return info;
}