feat: epub bookmarks (#1337)

Co-authored-by: vedi0boy <nate@origin8publishing.com>
Co-authored-by: Uri Tauber <uritaube@gmail.com>
This commit is contained in:
Nathanael Maher
2026-05-27 15:52:01 -04:00
committed by GitHub
co-authored by vedi0boy Uri Tauber
parent bbb3e06eb1
commit 36a3a0cc3a
26 changed files with 700 additions and 128 deletions
+13 -2
View File
@@ -35,6 +35,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [Supported Languages](#supported-languages) - [Supported Languages](#supported-languages)
- [5. Reader Menu](#5-reader-menu) - [5. Reader Menu](#5-reader-menu)
- [5.1 Chapter Selection](#51-chapter-selection) - [5.1 Chapter Selection](#51-chapter-selection)
- [5.2. Bookmarks](#52-bookmarks)
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap) - [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop) - [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
@@ -502,7 +503,17 @@ Accessible by selecting **Chapters** from the Reader Menu.
--- ---
## 6. Current Limitations & Roadmap ### 5.2 Bookmarks
Bookmarks can be created to quickly save and restore your place in a book.
To create a bookmark, hold **Confirm** for 1 second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for 1 second, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format.
## 7. Current Limitations & Roadmap
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
@@ -514,7 +525,7 @@ Please note that this firmware is currently in active development. The following
--- ---
## 7. Troubleshooting Issues & Escaping Bootloop ## 8. Troubleshooting Issues & Escaping Bootloop
If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the logs. If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the logs.
+37
View File
@@ -329,6 +329,43 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return page; return page;
} }
std::string Section::getTextFromSectionFile() {
std::string fullText;
auto p = this->loadPageFromSectionFile();
if (p) {
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;
}
}
}
}
}
return fullText;
}
std::optional<uint16_t> Section::getCachedPageCount() const {
HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
if (fileSize < HEADER_SIZE) {
return std::nullopt;
}
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
uint16_t count;
serialization::readPod(f, count);
return count;
}
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const { std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
HalFile f; HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) { if (!Storage.openFileForRead("SCT", filePath, f)) {
+4
View File
@@ -40,10 +40,14 @@ class Section {
uint8_t imageRendering, bool focusReadingEnabled, uint8_t imageRendering, bool focusReadingEnabled,
const std::function<void()>& popupFn = nullptr); const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile(); std::unique_ptr<Page> loadPageFromSectionFile();
std::string getTextFromSectionFile();
// Look up the page number for an anchor id from the section cache file. // Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const; std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Get the page count from the section cache file without fully loading it.
std::optional<uint16_t> getCachedPageCount() const;
// Look up the page number for a synthetic paragraph index from XPath p[N]. // Look up the page number for a synthetic paragraph index from XPath p[N].
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const; std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
+5
View File
@@ -182,6 +182,8 @@ STR_DOWNLOADING: "Downloading..."
STR_DOWNLOAD_FAILED: "Download failed" STR_DOWNLOAD_FAILED: "Download failed"
STR_ERROR_MSG: "Error:" STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Unnamed" STR_UNNAMED: "Unnamed"
STR_HOLD_CONFIRM_TO_DELETE: "Hold Confirm to Delete"
STR_BOOKMARK_INSTRUCTIONS: "Hold Confirm from the reader to create a bookmark."
STR_NO_SERVER_URL: "No server URL configured" STR_NO_SERVER_URL: "No server URL configured"
STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_FETCH_FEED_FAILED: "Failed to fetch feed"
STR_PARSE_FEED_FAILED: "Failed to parse feed" STR_PARSE_FEED_FAILED: "Failed to parse feed"
@@ -259,6 +261,8 @@ STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
STR_BOOKMARKS: "Bookmarks"
STR_BOOKMARK_ADDED: "Bookmark added."
STR_OPDS_BROWSER: "OPDS Browser" STR_OPDS_BROWSER: "OPDS Browser"
STR_SEARCH: "Search" STR_SEARCH: "Search"
STR_COVER_CUSTOM: "Cover + Custom" STR_COVER_CUSTOM: "Cover + Custom"
@@ -288,6 +292,7 @@ STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress" STR_SYNC_PROGRESS: "Sync Progress"
STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE_CACHE: "Delete Book Cache"
STR_DELETE: "Delete" STR_DELETE: "Delete"
STR_CONFIRM_DELETE_BOOKMARK: "Delete this bookmark?"
STR_DISPLAY_QR: "Show page as QR" STR_DISPLAY_QR: "Show page as QR"
STR_CHAPTER_PREFIX: "Chapter: " STR_CHAPTER_PREFIX: "Chapter: "
STR_PAGES_SEPARATOR: " pages | " STR_PAGES_SEPARATOR: " pages | "
+73 -8
View File
@@ -1,5 +1,6 @@
#include "ProgressMapper.h" #include "ProgressMapper.h"
#include <GfxRenderer.h>
#include <Logging.h> #include <Logging.h>
#include <algorithm> #include <algorithm>
@@ -7,6 +8,7 @@
#include <cstring> #include <cstring>
#include "ChapterXPathResolver.h" #include "ChapterXPathResolver.h"
#include "Epub/Section.h"
#include "Epub/htmlEntities.h" #include "Epub/htmlEntities.h"
#include "Utf8.h" #include "Utf8.h"
@@ -506,8 +508,9 @@ bool streamSpine(const std::shared_ptr<Epub>& epub, int spineIndex, ParagraphStr
} }
} // namespace } // namespace
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) { SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub>& epub,
KOReaderPosition result; const CrossPointPosition& pos) {
SavedProgressPosition result;
float intra = float intra =
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f; (pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
result.percentage = epub->calculateProgress(pos.spineIndex, intra); result.percentage = epub->calculateProgress(pos.spineIndex, intra);
@@ -520,13 +523,14 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
if (result.xpath.empty()) { if (result.xpath.empty()) {
result.xpath = generateXPath(epub, pos.spineIndex, intra); result.xpath = generateXPath(epub, pos.spineIndex, intra);
} }
LOG_DBG("PM", "-> KO: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages, LOG_DBG("PM", "-> Progress: spine=%d page=%d/%d %.2f%% %s", pos.spineIndex, pos.pageNumber, pos.totalPages,
result.percentage * 100, result.xpath.c_str()); result.percentage * 100, result.xpath.c_str());
return result; return result;
} }
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos, CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
int currentSpineIndex, int totalPagesInCurrentSpine) { GfxRenderer& renderer, int currentSpineIndex,
int totalPagesInCurrentSpine, int fallbackTotalPages) {
CrossPointPosition result{}; CrossPointPosition result{};
const size_t bookSize = epub->getBookSize(); const size_t bookSize = epub->getBookSize();
if (bookSize == 0) return result; if (bookSize == 0) return result;
@@ -556,7 +560,6 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
} }
} }
} }
if (result.spineIndex >= spineCount) return result;
const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; const size_t prevCum = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum; const size_t spineSize = epub->getCumulativeSpineItemSize(result.spineIndex) - prevCum;
@@ -570,7 +573,17 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.totalPages = std::max( result.totalPages = std::max(
1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs))); 1, static_cast<int>(totalPagesInCurrentSpine * static_cast<float>(spineSize) / static_cast<float>(cs)));
} }
if (spineSize == 0 || result.totalPages == 0) return result;
if (result.totalPages <= 0) {
Section tempSection(epub, result.spineIndex, renderer);
if (auto cachedCount = tempSection.getCachedPageCount()) {
result.totalPages = *cachedCount;
} else if (fallbackTotalPages > 0) {
result.totalPages = fallbackTotalPages;
} else {
result.totalPages = 1; // Prevent division by zero and give a fallback
}
}
float intra = 0.0f; float intra = 0.0f;
if (useAncestry) { if (useAncestry) {
@@ -613,8 +626,60 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.pageNumber = std::max( result.pageNumber = std::max(
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1)); 0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
LOG_DBG("PM", "<- KO: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(), LOG_DBG("PM", "<- Progress: %.2f%% %s -> spine=%d page=%d/%d", koPos.percentage * 100, koPos.xpath.c_str(),
result.spineIndex, result.pageNumber, result.totalPages); result.spineIndex, result.pageNumber, result.totalPages);
// Refine page using section cache LUTs: li index, anchor, or paragraph index.
if (result.hasLiIndex || result.xpathAnchorId[0] != '\0' || result.hasParagraphIndex) {
Section tempSection(epub, result.spineIndex, renderer);
bool refined = false;
if (result.hasLiIndex) {
const auto liPage = tempSection.getPageForListItemIndex(result.liIndex);
if (liPage.has_value()) {
LOG_DBG("PM", "Li index %u -> page %d (was %d)", result.liIndex, *liPage, result.pageNumber);
result.pageNumber = *liPage;
refined = true;
} else {
LOG_DBG("PM", "Li index %u not found in section LUT", result.liIndex);
}
}
if (!refined && result.xpathAnchorId[0] != '\0') {
const auto anchorPage = tempSection.getPageForAnchor(std::string(result.xpathAnchorId));
if (anchorPage.has_value()) {
LOG_DBG("PM", "Anchor '%s' -> page %d (was %d)", result.xpathAnchorId, *anchorPage, result.pageNumber);
result.pageNumber = *anchorPage;
refined = true;
} else {
LOG_DBG("PM", "Anchor '%s' not found in section cache", result.xpathAnchorId);
}
}
if (!refined && result.hasParagraphIndex) {
const auto paragraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex);
const auto nextParagraphPage = tempSection.getPageForParagraphIndex(result.paragraphIndex + 1);
if (paragraphPage.has_value()) {
int refinedPage = std::max(result.pageNumber, static_cast<int>(*paragraphPage));
if (nextParagraphPage.has_value()) {
const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*paragraphPage);
// Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too
// coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph
// occupies different pages than at build time).
if (lutSpan > 1 && refinedPage >= static_cast<int>(*nextParagraphPage)) {
refinedPage = static_cast<int>(*nextParagraphPage) - 1;
}
}
char nextParaBuf[8];
if (nextParagraphPage.has_value())
snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage);
else
snprintf(nextParaBuf, sizeof(nextParaBuf), "none");
LOG_DBG("PM", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d", result.paragraphIndex,
*paragraphPage, nextParaBuf, result.pageNumber, refinedPage);
result.pageNumber = refinedPage;
} else {
LOG_DBG("PM", "Paragraph %u not found in section LUT", result.paragraphIndex);
}
}
}
return result; return result;
} }
+14 -11
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -19,18 +20,18 @@ struct CrossPointPosition {
}; };
/** /**
* KOReader position representation. * Progress position representation.
*/ */
struct KOReaderPosition { struct SavedProgressPosition {
std::string xpath; // XPath-like progress string std::string xpath; // XPath-like progress string
float percentage; // Progress percentage (0.0 to 1.0) float percentage; // Progress percentage (0.0 to 1.0)
}; };
/** /**
* Maps between CrossPoint and KOReader position formats. * Maps between CrossPoint and SavedProgress position formats, such as those used by KOReader.
* *
* CrossPoint tracks position as (spineIndex, pageNumber). * CrossPoint tracks position as (spineIndex, pageNumber).
* KOReader uses XPath-like strings + percentage. * SavedProgress uses XPath-like strings + percentage.
* *
* Since CrossPoint discards HTML structure during parsing, we generate * Since CrossPoint discards HTML structure during parsing, we generate
* synthetic XPath strings based on spine index, using percentage as the * synthetic XPath strings based on spine index, using percentage as the
@@ -39,28 +40,30 @@ struct KOReaderPosition {
class ProgressMapper { class ProgressMapper {
public: public:
/** /**
* Convert CrossPoint position to KOReader format. * Convert CrossPoint position to SavedProgress format.
* *
* @param epub The EPUB book * @param epub The EPUB book
* @param pos CrossPoint position * @param pos CrossPoint position
* @return KOReader position * @return SavedProgress position
*/ */
static KOReaderPosition toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos); static SavedProgressPosition toSavedProgress(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos);
/** /**
* Convert KOReader position to CrossPoint format. * Convert SavedProgress position to CrossPoint format.
* *
* Note: The returned pageNumber may be approximate since different * Note: The returned pageNumber may be approximate since different
* rendering settings produce different page counts. * rendering settings produce different page counts.
* *
* @param epub The EPUB book * @param epub The EPUB book
* @param koPos KOReader position * @param savedPos SavedProgress position
* @param renderer GfxRenderer for page count estimation
* @param currentSpineIndex Index of the currently open spine item (for density estimation) * @param currentSpineIndex Index of the currently open spine item (for density estimation)
* @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation) * @param totalPagesInCurrentSpine Total pages in the current spine item (for density estimation)
* @return CrossPoint position * @return CrossPoint position
*/ */
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos, static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& savedPos,
int currentSpineIndex = -1, int totalPagesInCurrentSpine = 0); GfxRenderer& renderer, int currentSpineIndex = -1,
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
private: private:
/** /**
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
#include <string>
// A single bookmark entry — a position in a book.
struct BookmarkEntry {
std::string xpath; // XPath-like progress string
std::string summary; // First few words of a page to help identify it
float percentage; // Progress percentage (0.0 to 1.0)
uint16_t computedSpineIndex = 0; // Spine index at the time of bookmarking
uint16_t computedChapterPageCount = 0; // Total page count of the chapter at the time of bookmarking
uint16_t computedChapterProgress = 0; // Number of pages into the chapter at the time of bookmarking
};
+42
View File
@@ -8,6 +8,7 @@
#include <cstring> #include <cstring>
#include <string> #include <string>
#include "BookmarkEntry.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "OpdsServerStore.h" #include "OpdsServerStore.h"
@@ -394,3 +395,44 @@ bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* ne
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
return true; return true;
} }
// ---- Bookmarks ----
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
JsonDocument doc;
JsonArray arr = doc["bookmarks"].to<JsonArray>();
LOG_DBG("BKM", "Saving %zu bookmarks to file", bookmarks.size());
for (const auto& bookmark : bookmarks) {
JsonObject obj = arr.add<JsonObject>();
obj["xpath"] = bookmark.xpath;
obj["percentage"] = bookmark.percentage;
obj["summary"] = bookmark.summary;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json) {
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("BKM", "JSON parse error: %s", error.c_str());
return false;
}
JsonArray arr = doc["bookmarks"].as<JsonArray>();
bookmarks.clear();
bookmarks.reserve(arr.size());
for (JsonObject obj : arr) {
bookmarks.emplace_back();
auto& bookmark = bookmarks.back();
bookmark.xpath = obj["xpath"] | std::string("");
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
bookmark.summary = obj["summary"] | std::string("");
}
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
return true;
}
+7
View File
@@ -1,10 +1,13 @@
#pragma once #pragma once
#include <vector>
class CrossPointSettings; class CrossPointSettings;
class CrossPointState; class CrossPointState;
class WifiCredentialStore; class WifiCredentialStore;
class RecentBooksStore; class RecentBooksStore;
class OpdsServerStore; class OpdsServerStore;
struct BookmarkEntry;
namespace JsonSettingsIO { namespace JsonSettingsIO {
@@ -28,4 +31,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json);
bool saveOpds(const OpdsServerStore& store, const char* path); bool saveOpds(const OpdsServerStore& store, const char* path);
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
// Bookmarks
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
} // namespace JsonSettingsIO } // namespace JsonSettingsIO
+2 -2
View File
@@ -36,7 +36,7 @@ struct PageResult {
uint32_t page = 0; uint32_t page = 0;
}; };
struct SyncResult { struct ProgressChangeResult {
int spineIndex = 0; int spineIndex = 0;
int page = 0; int page = 0;
}; };
@@ -56,7 +56,7 @@ struct FilePathResult {
}; };
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
PageResult, SyncResult, NetworkModeResult, FootnoteResult, FilePathResult>; PageResult, ProgressChangeResult, NetworkModeResult, FootnoteResult, FilePathResult>;
struct ActivityResult { struct ActivityResult {
bool isCancelled = false; bool isCancelled = false;
+145 -45
View File
@@ -7,16 +7,20 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <I18n.h> #include <I18n.h>
#include <JsonSettingsIO.h>
#include <Logging.h> #include <Logging.h>
#include <Memory.h> #include <Memory.h>
#include <esp_system.h> #include <esp_system.h>
#include <algorithm>
#include <functional> #include <functional>
#include <iterator> #include <iterator>
#include <limits> #include <limits>
#include "BookmarkEntry.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderBookmarksActivity.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h" #include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderPercentSelectionActivity.h"
@@ -30,6 +34,7 @@
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BookmarkUtil.h"
#include "util/ScreenshotUtil.h" #include "util/ScreenshotUtil.h"
namespace { namespace {
@@ -246,28 +251,48 @@ void EpubReaderActivity::loop() {
} }
} }
if (showBookmarkMessage && (millis() - bookmarkMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
showBookmarkMessage = false;
requestUpdate();
}
// Enter reader menu activity. // Enter reader menu activity.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const int currentPage = section ? section->currentPage + 1 : 0; if (ignoreNextConfirmRelease) {
const int totalPages = section ? section->pageCount : 0; ignoreNextConfirmRelease = false;
float bookProgress = 0.0f; } else {
if (epub->getBookSize() > 0 && section && section->pageCount > 0) { const int currentPage = section ? section->currentPage + 1 : 0;
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount); const int totalPages = section ? section->pageCount : 0;
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; 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));
}
});
}
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
if (!showBookmarkMessage) {
addBookmark();
showBookmarkMessage = true;
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
bookmarkMessageTime = millis();
requestUpdate();
} }
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 // Long press BACK (1s+) goes to file selection
@@ -410,6 +435,18 @@ void EpubReaderActivity::jumpToPercent(int percent) {
} }
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
auto progressChangeResultHandler = [this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
section.reset();
}
}
};
switch (action) { switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
const int spineIdx = currentSpineIndex; const int spineIdx = currentSpineIndex;
@@ -463,26 +500,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: { case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
auto p = section->loadPageFromSectionFile(); std::string fullText = section->getTextFromSectionFile();
if (p) { if (!fullText.empty()) {
std::string fullText; startActivityForResult(std::make_unique<QrDisplayActivity>(renderer, mappedInput, fullText),
for (const auto& el : p->elements) { [this](const ActivityResult& result) {});
if (el->getTag() == TAG_PageLine) { break;
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 // If no text or page loading failed, just close menu
@@ -533,12 +555,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
// Pre-compute local KO position and chapter name while Epub is still in RAM. // Pre-compute local KO position and chapter name while Epub is still in RAM.
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages}; CrossPointPosition localPos = getCurrentPosition();
if (paragraphIndex.has_value()) { SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
localPos.paragraphIndex = *paragraphIndex;
localPos.hasParagraphIndex = true;
}
KOReaderPosition localKoPos = ProgressMapper::toKOReader(epub, localPos);
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : ""; std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
const std::string savedEpubPath = epub->getPath(); const std::string savedEpubPath = epub->getPath();
@@ -570,6 +588,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
break; break;
} }
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
startActivityForResult(
std::make_unique<EpubReaderBookmarksActivity>(renderer, mappedInput, epub, epub->getPath()),
progressChangeResultHandler);
break;
}
} }
} }
@@ -838,6 +862,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
pendingScreenshot = false; pendingScreenshot = false;
ScreenshotUtil::takeScreenshot(renderer); ScreenshotUtil::takeScreenshot(renderer);
} }
if (showBookmarkMessage) {
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
}
} }
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
@@ -1116,6 +1144,58 @@ void EpubReaderActivity::restoreSavedPosition() {
requestUpdate(); requestUpdate();
} }
void EpubReaderActivity::addBookmark() {
if (!section || !epub) {
return;
}
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
int currentPage;
int pageCount;
{
RenderLock lock(*this);
pageCount = section->pageCount;
currentPage = section->currentPage;
}
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
}
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
// Add bookmark
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
Storage.mkdir(bookmarksDir.c_str());
std::vector<BookmarkEntry> bookmarks;
if (Storage.exists(path.c_str())) {
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
String json = Storage.readFile(path.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
}
} else {
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
}
bookmarks.insert(bookmarks.begin(), entry);
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
if (ok) {
showBookmarkMessage = true;
} else {
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
}
requestUpdate();
}
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const { ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info; ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Epub; info.readerType = ScreenshotInfo::ReaderType::Epub;
@@ -1136,3 +1216,23 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
} }
return info; return info;
} }
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
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;
}
}
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPages};
if (paragraphIndex.has_value()) {
localPos.paragraphIndex = *paragraphIndex;
localPos.hasParagraphIndex = true;
}
return localPos;
}
@@ -6,6 +6,7 @@
#include <optional> #include <optional>
#include "EpubReaderMenuActivity.h" #include "EpubReaderMenuActivity.h"
#include "ProgressMapper.h"
#include "activities/Activity.h" #include "activities/Activity.h"
class EpubReaderActivity final : public Activity { class EpubReaderActivity final : public Activity {
@@ -31,9 +32,12 @@ class EpubReaderActivity final : public Activity {
bool pendingSyncSaveError = false; bool pendingSyncSaveError = false;
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool automaticPageTurnActive = false; bool automaticPageTurnActive = false;
bool showBookmarkMessage = false;
bool ignoreNextConfirmRelease = false;
// Tracks whether this book is currently removed from Recent Books by the // Tracks whether this book is currently removed from Recent Books by the
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in). // removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
bool recentsEntryRemoved = false; bool recentsEntryRemoved = false;
unsigned long bookmarkMessageTime = 0UL;
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on. // Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
// Consumed in onExit() to relocate the finished book into /Read/. // Consumed in onExit() to relocate the finished book into /Read/.
bool pendingReadFolderMove = false; bool pendingReadFolderMove = false;
@@ -59,6 +63,7 @@ class EpubReaderActivity final : public Activity {
void applyOrientation(uint8_t orientation); void applyOrientation(uint8_t orientation);
void toggleAutoPageTurn(uint8_t selectedPageTurnOption); void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
void pageTurn(bool isForwardTurn); void pageTurn(bool isForwardTurn);
void addBookmark();
// Footnote navigation // Footnote navigation
void navigateToHref(const std::string& href, bool savePosition = false); void navigateToHref(const std::string& href, bool savePosition = false);
@@ -73,4 +78,5 @@ class EpubReaderActivity final : public Activity {
void render(RenderLock&& lock) override; void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; } bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override; ScreenshotInfo getScreenshotInfo() const override;
CrossPointPosition getCurrentPosition() const;
}; };
@@ -0,0 +1,224 @@
#include "EpubReaderBookmarksActivity.h"
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <JsonSettingsIO.h>
#include <util/BookmarkUtil.h>
#include <algorithm>
#include "MappedInputManager.h"
#include "ProgressMapper.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr int ENTER_DELETE_MODE_MS = 700;
constexpr int DELETE_MODE_OFF = 0;
constexpr int DELETE_MODE_DISPLAY = 1;
constexpr int DELETE_MODE_CONFIRM = 2;
// Layout constants used in renderScreen
constexpr int LINE_HEIGHT = 60;
} // namespace
void EpubReaderBookmarksActivity::onEnter() {
Activity::onEnter();
if (!epub) {
return;
}
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
if (Storage.exists(path.c_str())) {
String json = Storage.readFile(path.c_str());
if (json.isEmpty()) {
LOG_ERR("EPB", "Failed to load bookmarks from %s. Empty bookmark file", path.c_str());
bookmarks.clear();
bookmarks.shrink_to_fit();
} else {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
// pre-compute bookmark page values for quicker rendering
for (auto& bookmark : bookmarks) {
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
bookmark.computedSpineIndex = pos.spineIndex;
bookmark.computedChapterPageCount = pos.totalPages;
bookmark.computedChapterProgress = pos.pageNumber;
}
}
} else {
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
bookmarks.clear();
bookmarks.shrink_to_fit();
}
LOG_DBG("EPB", "Loaded %d bookmarks for book: %s", static_cast<int>(bookmarks.size()), epubPath.c_str());
// Trigger first update
requestUpdate();
}
void EpubReaderBookmarksActivity::onExit() { Activity::onExit(); }
int EpubReaderBookmarksActivity::getGutterBottom(const GfxRenderer& renderer) {
const auto orientation = renderer.getOrientation();
const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait;
return isPortrait ? 75 : 40; // Reserve vertical space for button hints at the bottom
}
int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
const auto pageHeight = renderer.getScreenHeight();
return pageHeight - getGutterBottom(renderer) - LINE_HEIGHT; // Reserve vertical space for title and button hints
}
void EpubReaderBookmarksActivity::loop() {
// Delete confirmation mode
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (confirmingDelete == DELETE_MODE_DISPLAY) {
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
requestUpdate();
return;
}
bookmarks.erase(bookmarks.begin() + selectorIndex);
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
LOG_ERR("EPB", "Failed to save bookmarks after delete");
}
// Move selector up if we deleted the last item
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
selectorIndex--;
}
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
if (bookmarks.empty()) {
return;
}
auto bookmark = bookmarks.at(selectorIndex);
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
finish();
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
if (bookmarks.empty()) {
return;
}
confirmingDelete = DELETE_MODE_DISPLAY;
requestUpdate();
}
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, bookmarks.size());
requestUpdate();
});
buttonNavigator.onPreviousRelease([this] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, bookmarks.size());
requestUpdate();
});
buttonNavigator.onNextContinuous([this] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(),
GUI.getListPageItems(getListHeight(renderer), true));
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(),
GUI.getListPageItems(getListHeight(renderer), true));
requestUpdate();
});
}
void EpubReaderBookmarksActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
// Landscape orientation: reserve a horizontal gutter for button hints.
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
// Inverted portrait: reserve vertical space for hints at the top.
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
// Landscape CW places hints on the left edge; CCW keeps them on the right.
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int hintGutterBottom = getGutterBottom(renderer);
const int contentY = hintGutterHeight;
const int listY = contentY + LINE_HEIGHT; // Reserve vertical space for title
const int listHeight = getListHeight(renderer);
const int numBookmarks = bookmarks.size();
// Manual centering to honor content gutters.
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_BOOKMARKS), EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
const auto getBookmarkTitle = [this](int index) {
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
};
const auto getBookmarkSubtitle = [this](int index) {
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
return std::to_string((int)bookmark.percentage) + "% - " + std::to_string(bookmark.computedChapterProgress) + "/" +
std::to_string(bookmark.computedChapterPageCount) + " - " + tocTitle;
};
const auto getBookmarkIcon = [isPortrait](int index) {
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
return isPortrait ? UIIcon::Bookmark : UIIcon::None;
};
if (numBookmarks > 0) {
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_CONFIRM_DELETE_BOOKMARK));
// render list with just the selected item for the user to confirm to delete
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
getBookmarkSubtitle, getBookmarkIcon);
} else {
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT},
tr(STR_HOLD_CONFIRM_TO_DELETE));
}
} else {
GUI.drawHelpText(renderer, Rect{contentX, LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_BOOKMARK_INSTRUCTIONS));
}
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
const auto confirmLabel =
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_OPEN)) : "";
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,33 @@
#pragma once
#include <Epub.h>
#include <memory>
#include "../../BookmarkEntry.h"
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarksActivity final : public Activity {
std::shared_ptr<Epub> epub;
std::string epubPath;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
std::vector<BookmarkEntry> bookmarks;
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
public:
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const std::string& epubPath)
: Activity("EpubReaderBookmarks", renderer, mappedInput), epub(epub), epubPath(epubPath) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
// Calculate the vertical space to reserve for button hints based on orientation
int getGutterBottom(const GfxRenderer& renderer);
// Calculate the height available for the bookmark list based on orientation
int getListHeight(const GfxRenderer& renderer);
};
@@ -21,11 +21,12 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
std::vector<MenuItem> items; std::vector<MenuItem> items;
items.reserve(10); items.reserve(11);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) { if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
} }
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}); items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN}); items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
@@ -17,6 +17,7 @@ class EpubReaderMenuActivity final : public Activity {
GO_TO_PERCENT, GO_TO_PERCENT,
AUTO_PAGE_TURN, AUTO_PAGE_TURN,
ROTATE_SCREEN, ROTATE_SCREEN,
BOOKMARKS,
SCREENSHOT, SCREENSHOT,
DISPLAY_QR, DISPLAY_QR,
GO_HOME, GO_HOME,
+2 -56
View File
@@ -174,64 +174,10 @@ void KOReaderSyncActivity::performSync() {
return; return;
} }
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine); remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
// Refine page using section cache LUTs: li index, anchor, or paragraph index.
if (remotePosition.hasLiIndex || remotePosition.xpathAnchorId[0] != '\0' || remotePosition.hasParagraphIndex) {
Section tempSection(epub, remotePosition.spineIndex, renderer);
bool refined = false;
if (remotePosition.hasLiIndex) {
const auto liPage = tempSection.getPageForListItemIndex(remotePosition.liIndex);
if (liPage.has_value()) {
LOG_DBG("KOSync", "Li index %u -> page %d (was %d)", remotePosition.liIndex, *liPage,
remotePosition.pageNumber);
remotePosition.pageNumber = *liPage;
refined = true;
} else {
LOG_DBG("KOSync", "Li index %u not found in section LUT", remotePosition.liIndex);
}
}
if (!refined && remotePosition.xpathAnchorId[0] != '\0') {
const auto anchorPage = tempSection.getPageForAnchor(std::string(remotePosition.xpathAnchorId));
if (anchorPage.has_value()) {
LOG_DBG("KOSync", "Anchor '%s' -> page %d (was %d)", remotePosition.xpathAnchorId, *anchorPage,
remotePosition.pageNumber);
remotePosition.pageNumber = *anchorPage;
refined = true;
} else {
LOG_DBG("KOSync", "Anchor '%s' not found in section cache", remotePosition.xpathAnchorId);
}
}
if (!refined && remotePosition.hasParagraphIndex) {
const auto paragraphPage = tempSection.getPageForParagraphIndex(remotePosition.paragraphIndex);
const auto nextParagraphPage = tempSection.getPageForParagraphIndex(remotePosition.paragraphIndex + 1);
if (paragraphPage.has_value()) {
int refinedPage = std::max(remotePosition.pageNumber, static_cast<int>(*paragraphPage));
if (nextParagraphPage.has_value()) {
const int lutSpan = static_cast<int>(*nextParagraphPage) - static_cast<int>(*paragraphPage);
// Only cap when the LUT span is >1. A span of 1 means the LUT granularity is too
// coarse to trust over the intra-spine position (e.g. a stale cache where the paragraph
// occupies different pages than at build time).
if (lutSpan > 1 && refinedPage >= static_cast<int>(*nextParagraphPage)) {
refinedPage = static_cast<int>(*nextParagraphPage) - 1;
}
}
char nextParaBuf[8];
if (nextParagraphPage.has_value())
snprintf(nextParaBuf, sizeof(nextParaBuf), "%d", *nextParagraphPage);
else
snprintf(nextParaBuf, sizeof(nextParaBuf), "none");
LOG_DBG("KOSync", "Paragraph %u -> LUT page %d, nextPara page %s, intra page %d, using %d",
remotePosition.paragraphIndex, *paragraphPage, nextParaBuf, remotePosition.pageNumber, refinedPage);
remotePosition.pageNumber = refinedPage;
} else {
LOG_DBG("KOSync", "Paragraph %u not found in section LUT", remotePosition.paragraphIndex);
}
}
}
// localProgress was pre-computed in EpubReaderActivity before the Epub was released. // localProgress was pre-computed in EpubReaderActivity before the Epub was released.
{ {
RenderLock lock(*this); RenderLock lock(*this);
state = SHOWING_RESULT; state = SHOWING_RESULT;
+2 -2
View File
@@ -23,7 +23,7 @@ class KOReaderSyncActivity final : public Activity {
public: public:
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath, explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath,
int currentSpineIndex, int currentPage, int totalPagesInSpine, int currentSpineIndex, int currentPage, int totalPagesInSpine,
KOReaderPosition localKoPos, std::string localChapterName, SavedProgressPosition localKoPos, std::string localChapterName,
std::optional<uint16_t> currentParagraphIndex = std::nullopt) std::optional<uint16_t> currentParagraphIndex = std::nullopt)
: Activity("KOReaderSync", renderer, mappedInput), : Activity("KOReaderSync", renderer, mappedInput),
epubPath(epubPath), epubPath(epubPath),
@@ -73,7 +73,7 @@ class KOReaderSyncActivity final : public Activity {
CrossPointPosition remotePosition; CrossPointPosition remotePosition;
// Local progress as KOReader format (pre-computed before Epub was released) // Local progress as KOReader format (pre-computed before Epub was released)
KOReaderPosition localProgress; SavedProgressPosition localProgress;
// Selection in result screen (0=Apply, 1=Upload) // Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0; int selectedOption = 0;
+2
View File
@@ -11,6 +11,8 @@ namespace ReaderUtils {
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long SKIP_HOLD_MS = 700;
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
switch (orientation) { switch (orientation) {
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <cstdint>
// size: 32x32
static const uint8_t BookmarkIcon[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00,
0x03, 0x80, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x0F, 0x00,
0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x0F,
0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
+5
View File
@@ -228,6 +228,11 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
} }
} }
int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
return contentHeight / rowHeight;
}
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle, const std::function<std::string(int index)>& rowSubtitle,
+2 -1
View File
@@ -97,7 +97,7 @@ struct ThemeMetrics {
int textFieldLineEndOffset; int textFieldLineEndOffset;
}; };
enum UIIcon { Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot }; enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark };
enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled }; enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled };
@@ -182,6 +182,7 @@ class BaseTheme {
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const; const char* btn4) const;
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
virtual int getListPageItems(int contentHeight, bool hasSubtitle) const;
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr, const std::function<std::string(int index)>& rowSubtitle = nullptr,
+8
View File
@@ -14,6 +14,7 @@
#include "components/UITheme.h" #include "components/UITheme.h"
#include "components/icons/book.h" #include "components/icons/book.h"
#include "components/icons/book24.h" #include "components/icons/book24.h"
#include "components/icons/bookmark.h"
#include "components/icons/cover.h" #include "components/icons/cover.h"
#include "components/icons/file24.h" #include "components/icons/file24.h"
#include "components/icons/folder.h" #include "components/icons/folder.h"
@@ -73,6 +74,8 @@ const uint8_t* iconForName(UIIcon icon, int size) {
return WifiIcon; return WifiIcon;
case UIIcon::Hotspot: case UIIcon::Hotspot:
return HotspotIcon; return HotspotIcon;
case UIIcon::Bookmark:
return BookmarkIcon;
default: default:
return nullptr; return nullptr;
} }
@@ -202,6 +205,11 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
} }
int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
return contentHeight / rowHeight;
}
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle, const std::function<std::string(int index)>& rowSubtitle,
+1
View File
@@ -78,6 +78,7 @@ class LyraTheme : public BaseTheme {
const char* rightLabel = nullptr) const override; const char* rightLabel = nullptr) const override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const override; bool selected) const override;
int getListPageItems(int contentHeight, bool hasSubtitle) const override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle, const std::function<std::string(int index)>& rowSubtitle,
+35
View File
@@ -0,0 +1,35 @@
#include "BookmarkUtil.h"
#include <algorithm>
#include <string>
std::string BookmarkUtil::getBookmarksDir() { return "/.crosspoint/bookmarks/"; }
std::string BookmarkUtil::getBookmarkPath(const std::string& bookPath) {
// remove leading slash and replace internal slashes to create a flat filename
std::string bookName = std::string(bookPath).erase(0, 1);
std::replace(bookName.begin(), bookName.end(), '/', '_');
std::replace(bookName.begin(), bookName.end(), '\\', '_');
const size_t lastDot = bookName.find_last_of('.');
if (lastDot != std::string::npos) {
bookName.erase(lastDot);
}
bookName += ".json";
return getBookmarksDir() + bookName;
}
std::string BookmarkUtil::sanitizeBookmarkSummary(std::string summary) {
summary.erase(
std::unique(summary.begin(), summary.end(), [](char a, char b) { return std::isspace(a) && std::isspace(b); }),
summary.end());
summary.erase(std::remove(summary.begin(), summary.end(), '\n'), summary.end());
summary.erase(summary.begin(),
std::find_if(summary.begin(), summary.end(), [](unsigned char ch) { return !std::isspace(ch); }));
summary.erase(
std::find_if(summary.rbegin(), summary.rend(), [](unsigned char ch) { return !std::isspace(ch); }).base(),
summary.end());
if (summary.size() > 72) {
summary.resize(72);
}
return summary;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <string>
class BookmarkUtil {
public:
static std::string getBookmarksDir();
static std::string getBookmarkPath(const std::string& bookPath);
static std::string sanitizeBookmarkSummary(std::string summary);
};