From 03f0ce04cc5e448a3cb3cf106f2f5eb808535c5a Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Tue, 30 Dec 2025 13:02:46 +0100 Subject: [PATCH 1/5] Feature: go to text/start reference in epub guide section at first start (#156) This parses the guide section in the content.opf for text/start references and jumps to this on first open of the book. Currently, this behavior will be repeated in case the reader manually jumps to Chapter 0 and then re-opens the book. IMO, this is an acceptable edge case (for which I couldn't see a good fix other than to drag a "first open" boolean around). --------- Co-authored-by: Sam Davis Co-authored-by: Dave Allie --- lib/Epub/Epub.cpp | 30 ++++++++++++++ lib/Epub/Epub.h | 1 + lib/Epub/Epub/BookMetadataCache.cpp | 8 ++-- lib/Epub/Epub/BookMetadataCache.h | 1 + lib/Epub/Epub/parsers/ContentOpfParser.cpp | 41 ++++++++++++++++++++ lib/Epub/Epub/parsers/ContentOpfParser.h | 2 + src/activities/reader/EpubReaderActivity.cpp | 10 +++++ 7 files changed, 90 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index ec3106b2..29a89243 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -74,6 +74,7 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { bookMetadata.title = opfParser.title; bookMetadata.author = opfParser.author; bookMetadata.coverItemHref = opfParser.coverItemHref; + bookMetadata.textReferenceHref = opfParser.textReferenceHref; if (!opfParser.tocNcxPath.empty()) { tocNcxItem = opfParser.tocNcxPath; @@ -426,6 +427,35 @@ size_t Epub::getBookSize() const { return getCumulativeSpineItemSize(getSpineItemsCount() - 1); } +int Epub::getSpineIndexForTextReference() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis()); + return 0; + } + Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(), + bookMetadataCache->coreMetadata.coverItemHref.size(), + bookMetadataCache->coreMetadata.coverItemHref.c_str(), + bookMetadataCache->coreMetadata.textReferenceHref.size(), + bookMetadataCache->coreMetadata.textReferenceHref.c_str()); + + if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) { + // there was no textReference in epub, so we return 0 (the first chapter) + return 0; + } + + // loop through spine items to get the correct index matching the text href + for (size_t i = 0; i < getSpineItemsCount(); i++) { + if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) { + Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(), + bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i); + return i; + } + } + // This should not happen, as we checked for empty textReferenceHref earlier + Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis()); + return 0; +} + // Calculate progress in book uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { const size_t bookSize = getBookSize(); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index b68aac70..4bd9c46f 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -54,6 +54,7 @@ class Epub { int getSpineIndexForTocIndex(int tocIndex) const; int getTocIndexForSpineIndex(int spineIndex) const; size_t getCumulativeSpineItemSize(int spineIndex) const; + int getSpineIndexForTextReference() const; size_t getBookSize() const; uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 6cc7ea74..06b4f458 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -9,7 +9,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 2; +constexpr uint8_t BOOK_CACHE_VERSION = 3; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; @@ -87,8 +87,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta constexpr uint32_t headerASize = sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount); - const uint32_t metadataSize = - metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3; + const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + + metadata.textReferenceHref.size() + sizeof(uint32_t) * 4; const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount; const uint32_t lutOffset = headerASize + metadataSize; @@ -101,6 +101,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta serialization::writeString(bookFile, metadata.title); serialization::writeString(bookFile, metadata.author); serialization::writeString(bookFile, metadata.coverItemHref); + serialization::writeString(bookFile, metadata.textReferenceHref); // Loop through spine entries, writing LUT positions spineFile.seek(0); @@ -289,6 +290,7 @@ bool BookMetadataCache::load() { serialization::readString(bookFile, coreMetadata.title); serialization::readString(bookFile, coreMetadata.author); serialization::readString(bookFile, coreMetadata.coverItemHref); + serialization::readString(bookFile, coreMetadata.textReferenceHref); loaded = true; Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index a6cf945b..5f1862c5 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -10,6 +10,7 @@ class BookMetadataCache { std::string title; std::string author; std::string coverItemHref; + std::string textReferenceHref; }; struct SpineEntry { diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 721dc871..c9398778 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -127,6 +127,18 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } + if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { + self->state = IN_GUIDE; + // TODO Remove print + Serial.printf("[%lu] [COF] Entering guide state.\n", millis()); + if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n", + millis()); + } + return; + } + if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { bool isCover = false; std::string coverItemId; @@ -205,6 +217,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } } + // parse the guide + if (self->state == IN_GUIDE && (strcmp(name, "reference") == 0 || strcmp(name, "opf:reference") == 0)) { + std::string type; + std::string textHref; + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "type") == 0) { + type = atts[i + 1]; + if (type == "text" || type == "start") { + continue; + } else { + Serial.printf("[%lu] [COF] Skipping non-text reference in guide: %s\n", millis(), type.c_str()); + break; + } + } else if (strcmp(atts[i], "href") == 0) { + textHref = self->baseContentPath + atts[i + 1]; + } + } + if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { + Serial.printf("[%lu] [COF] Found %s reference in guide: %s.\n", millis(), type.c_str(), textHref.c_str()); + self->textReferenceHref = textHref; + } + return; + } } void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) { @@ -231,6 +266,12 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) return; } + if (self->state == IN_GUIDE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { + self->state = IN_PACKAGE; + self->tempItemStore.close(); + return; + } + if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_PACKAGE; self->tempItemStore.close(); diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index ad1f2957..245fca3b 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -15,6 +15,7 @@ class ContentOpfParser final : public Print { IN_BOOK_AUTHOR, IN_MANIFEST, IN_SPINE, + IN_GUIDE, }; const std::string& cachePath; @@ -35,6 +36,7 @@ class ContentOpfParser final : public Print { std::string author; std::string tocNcxPath; std::string coverItemHref; + std::string textReferenceHref; explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 118decb3..c4a6690a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -65,6 +65,16 @@ void EpubReaderActivity::onEnter() { } f.close(); } + // We may want a better condition to detect if we are opening for the first time. + // This will trigger if the book is re-opened at Chapter 0. + if (currentSpineIndex == 0) { + int textSpineIndex = epub->getSpineIndexForTextReference(); + if (textSpineIndex != 0) { + currentSpineIndex = textSpineIndex; + Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(), + textSpineIndex); + } + } // Save current epub as last opened epub APP_STATE.openEpubPath = epub->getPath(); From 3abcd0d05d185b8cfdc17bf6d0a57d5103a32e38 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 30 Dec 2025 22:18:10 +1000 Subject: [PATCH 2/5] Redesign home screen (#166) ## Summary * Redesigned home screen with big option to continue reading and slightly nicer options to navigate to core sections * Attempt to use the cached EPUB details (title, author) if they exist, otherwise fall back to file name * Adjusted button hints on home screen, removed Back option and changed left/right to up/down ## Additional Context * Core of this work comes from @ChandhokTannay in https://github.com/ChandhokTannay/crosspoint-reader/commit/1d36a86ef1f016e796ef993c14fb1f9ea2f0101d --- lib/Epub/Epub.cpp | 7 +- lib/Epub/Epub.h | 2 +- src/activities/home/HomeActivity.cpp | 233 +++++++++++++++++++++++---- src/activities/home/HomeActivity.h | 2 + 4 files changed, 208 insertions(+), 36 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 29a89243..ec009832 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -139,7 +139,7 @@ bool Epub::parseTocNcxFile() const { } // load in the meta data for the epub file -bool Epub::load() { +bool Epub::load(const bool buildIfMissing) { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); // Initialize spine/TOC cache @@ -151,6 +151,11 @@ bool Epub::load() { return true; } + // If we didn't load from cache above and we aren't allowed to build, fail now + if (!buildIfMissing) { + return false; + } + // Cache doesn't exist or is invalid, build it Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); setupCacheDir(); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 4bd9c46f..1b82462d 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -34,7 +34,7 @@ class Epub { } ~Epub() = default; std::string& getBasePath() { return contentBasePath; } - bool load(); + bool load(bool buildIfMissing = true); bool clearCache() const; void setupCacheDir() const; const std::string& getCachePath() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 294adb8a..02dc2395 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,5 +1,6 @@ #include "HomeActivity.h" +#include #include #include @@ -22,6 +23,33 @@ void HomeActivity::onEnter() { // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); + if (hasContinueReading) { + // Extract filename from path for display + lastBookTitle = APP_STATE.openEpubPath; + const size_t lastSlash = lastBookTitle.find_last_of('/'); + if (lastSlash != std::string::npos) { + lastBookTitle = lastBookTitle.substr(lastSlash + 1); + } + + const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : ""; + const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : ""; + // If epub, try to load the metadata for title/author + if (ext5 == ".epub") { + Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); + epub.load(false); + if (!epub.getTitle().empty()) { + lastBookTitle = std::string(epub.getTitle()); + } + if (!epub.getAuthor().empty()) { + lastBookAuthor = std::string(epub.getAuthor()); + } + } else if (ext5 == ".xtch") { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (ext4 == ".xtc") { + lastBookTitle.resize(lastBookTitle.length() - 4); + } + } + selectorIndex = 0; // Trigger first update @@ -103,51 +131,188 @@ void HomeActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "CrossPoint Reader", true, BOLD); + const auto pageHeight = renderer.getScreenHeight(); - // Draw selection - renderer.fillRect(0, 60 + selectorIndex * 30 - 2, pageWidth - 1, 30); + constexpr int margin = 20; + constexpr int bottomMargin = 60; - int menuY = 60; - int menuIndex = 0; + // --- Top "book" card for the current title (selectorIndex == 0) --- + const int bookWidth = pageWidth / 2; + const int bookHeight = pageHeight / 2; + const int bookX = (pageWidth - bookWidth) / 2; + constexpr int bookY = 30; + const bool bookSelected = hasContinueReading && selectorIndex == 0; - if (hasContinueReading) { - // Extract filename from path for display - std::string bookName = APP_STATE.openEpubPath; - const size_t lastSlash = bookName.find_last_of('/'); - if (lastSlash != std::string::npos) { - bookName = bookName.substr(lastSlash + 1); - } - // Remove .epub extension - if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") { - bookName.resize(bookName.length() - 5); + // Draw book card regardless, fill with message based on `hasContinueReading` + { + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); } - // Truncate if too long - std::string continueLabel = "Continue: " + bookName; - int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str()); - while (itemWidth > renderer.getScreenWidth() - 40 && continueLabel.length() > 8) { - continueLabel.replace(continueLabel.length() - 5, 5, "..."); - itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str()); - Serial.printf("[%lu] [HOM] width: %lu, pageWidth: %lu\n", millis(), itemWidth, pageWidth); - } + // Bookmark icon in the top-right corner of the card + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; + constexpr int bookmarkY = bookY + 1; - renderer.drawText(UI_10_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + // Main bookmark body (solid) + renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); + + // Carve out an inverted triangle notch at the bottom center to create angled points + const int notchHeight = bookmarkHeight / 2; // depth of the notch + for (int i = 0; i < notchHeight; ++i) { + const int y = bookmarkY + bookmarkHeight - 1 - i; + const int xStart = bookmarkX + i; + const int width = bookmarkWidth - 2 * i; + if (width <= 0) { + break; + } + // Draw a horizontal strip in the opposite color to "cut" the notch + renderer.fillRect(xStart, y, width, 1, bookSelected); + } } - renderer.drawText(UI_10_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + if (hasContinueReading) { + // Split into words (avoid stringstream to keep this light on the MCU) + std::vector words; + words.reserve(8); + size_t pos = 0; + while (pos < lastBookTitle.size()) { + while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { + ++pos; + } + if (pos >= lastBookTitle.size()) { + break; + } + const size_t start = pos; + while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { + ++pos; + } + words.emplace_back(lastBookTitle.substr(start, pos - start)); + } - renderer.drawText(UI_10_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + std::vector lines; + std::string currentLine; + // Extra padding inside the card so text doesn't hug the border + const int maxLineWidth = bookWidth - 40; + const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); - renderer.drawText(UI_10_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex); + for (auto& i : words) { + // If we just hit the line limit (3), stop processing words + if (lines.size() >= 3) { + // Limit to 3 lines + // Still have words left, so add ellipsis to last line + lines.back().append("..."); - const auto labels = mappedInput.mapLabels("Back", "Confirm", "Left", "Right"); + while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { + lines.back().resize(lines.back().size() - 5); + lines.back().append("..."); + } + break; + } + + int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && i.size() > 5) { + // Word itself is too long, trim it + i.resize(i.size() - 5); + i.append("..."); + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + } + + int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); + if (newLineWidth > 0) { + newLineWidth += spaceWidth; + } + newLineWidth += wordWidth; + + if (newLineWidth > maxLineWidth && !currentLine.empty()) { + // New line too long, push old line + lines.push_back(currentLine); + currentLine = i; + } else { + currentLine.append(" ").append(i); + } + } + + // If lower than the line limit, push remaining words + if (!currentLine.empty() && lines.size() < 3) { + lines.push_back(currentLine); + } + + // Book title text + int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); + if (!lastBookAuthor.empty()) { + totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + } + + // Vertically center the title block within the card + int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + + for (const auto& line : lines) { + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + titleYStart += renderer.getLineHeight(UI_12_FONT_ID); + } + + if (!lastBookAuthor.empty()) { + titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; + std::string trimmedAuthor = lastBookAuthor; + // Trim author if too long + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + trimmedAuthor.resize(trimmedAuthor.size() - 5); + trimmedAuthor.append("..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + } + + renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, + "Continue Reading", !bookSelected); + } else { + // No book to continue reading + const int y = + bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; + renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); + renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); + } + + // --- Bottom menu tiles (indices 1-3) --- + const int menuTileWidth = pageWidth - 2 * margin; + constexpr int menuTileHeight = 50; + constexpr int menuSpacing = 10; + constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + + int menuStartY = bookY + bookHeight + 20; + // Ensure we don't collide with the bottom button legend + const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; + if (menuStartY > maxMenuStartY) { + menuStartY = maxMenuStartY; + } + + for (int i = 0; i < 3; ++i) { + constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"}; + const int overallIndex = i + (getMenuItemCount() - 3); + constexpr int tileX = margin; + const int tileY = menuStartY + i * (menuTileHeight + menuSpacing); + const bool selected = selectorIndex == overallIndex; + + if (selected) { + renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight); + } else { + renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); + } + + const char* label = items[i]; + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); + const int textX = tileX + (menuTileWidth - textWidth) / 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text + + // Invert text when the tile is selected, to contrast with the filled background + renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); + } + + const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index fcbab549..b6c9767d 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,6 +13,8 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + std::string lastBookTitle; + std::string lastBookAuthor; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; From 52a0b5bbe98f8846710944dac3b584e5cc0a293b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 30 Dec 2025 23:18:51 +1100 Subject: [PATCH 3/5] Small cleanups from https://github.com/juicecultus/crosspoint-reader-x4 --- lib/EpdFont/EpdFont.cpp | 11 +++---- lib/Epub/Epub.cpp | 3 ++ .../Epub/parsers/ChapterHtmlSlimParser.cpp | 12 +++---- lib/GfxRenderer/GfxRenderer.h | 2 +- src/network/CrossPointWebServer.cpp | 33 +++++++++++++------ 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 661317d6..7dde633f 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -2,8 +2,7 @@ #include -inline int min(const int a, const int b) { return a < b ? a : b; } -inline int max(const int a, const int b) { return a < b ? b : a; } +#include void EpdFont::getTextBounds(const char* string, const int startX, const int startY, int* minX, int* minY, int* maxX, int* maxY) const { @@ -32,10 +31,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star continue; } - *minX = min(*minX, cursorX + glyph->left); - *maxX = max(*maxX, cursorX + glyph->left + glyph->width); - *minY = min(*minY, cursorY + glyph->top - glyph->height); - *maxY = max(*maxY, cursorY + glyph->top); + *minX = std::min(*minX, cursorX + glyph->left); + *maxX = std::max(*maxX, cursorX + glyph->left + glyph->width); + *minY = std::min(*minY, cursorY + glyph->top - glyph->height); + *maxY = std::max(*maxY, cursorY + glyph->top); cursorX += glyph->advanceX; } } diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index ec009832..fde2e16a 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -109,17 +109,20 @@ bool Epub::parseTocNcxFile() const { if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); + tempNcxFile.close(); return false; } const auto ncxBuffer = static_cast(malloc(1024)); if (!ncxBuffer) { Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis()); + tempNcxFile.close(); return false; } while (tempNcxFile.available()) { const auto readSize = tempNcxFile.read(ncxBuffer, 1024); + if (readSize == 0) break; const auto processedSize = ncxParser.write(ncxBuffer, readSize); if (processedSize != readSize) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index a2b61899..9f7fed9f 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -57,7 +57,6 @@ void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); - (void)atts; // Middle of skip if (self->skipUntilDepth < self->depth) { @@ -93,7 +92,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { self->startNewTextBlock(TextBlock::CENTER_ALIGN); - self->boldUntilDepth = min(self->boldUntilDepth, self->depth); + self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { if (strcmp(name, "br") == 0) { self->startNewTextBlock(self->currentTextBlock->getStyle()); @@ -101,9 +100,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(TextBlock::JUSTIFIED); } } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { - self->boldUntilDepth = min(self->boldUntilDepth, self->depth); + self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); + self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); } self->depth += 1; @@ -162,7 +161,6 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { auto* self = static_cast(userData); - (void)name; if (self->partWordBufferIndex > 0) { // Only flush out part word buffer if we're closing a block tag or are at the top of the HTML file. @@ -245,9 +243,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - const size_t len = file.read(static_cast(buf), 1024); + const size_t len = file.read(buf, 1024); - if (len == 0) { + if (len == 0 && file.available() > 0) { Serial.printf("[%lu] [EHP] File read error\n", millis()); XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index f6f5fe0c..0d7bb885 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -37,7 +37,7 @@ class GfxRenderer { public: explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} - ~GfxRenderer() = default; + ~GfxRenderer() { freeBwBufferChunks(); } static constexpr int VIEWABLE_MARGIN_TOP = 9; static constexpr int VIEWABLE_MARGIN_RIGHT = 3; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 916f6a26..3a26a736 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -50,6 +50,14 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); server.reset(new WebServer(port)); + + // Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors. + // This is critical for reliable web server operation on ESP32. + WiFi.setSleep(false); + + // Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library. + // We rely on disabling WiFi sleep for responsiveness. + Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); if (!server) { @@ -157,15 +165,16 @@ void CrossPointWebServer::handleStatus() const { // Get correct IP based on AP vs STA mode const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); - String json = "{"; - json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; - json += "\"ip\":\"" + ipAddr + "\","; - json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\","; - json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode - json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; - json += "\"uptime\":" + String(millis() / 1000); - json += "}"; + JsonDocument doc; + doc["version"] = CROSSPOINT_VERSION; + doc["ip"] = ipAddr; + doc["mode"] = apMode ? "AP" : "STA"; + doc["rssi"] = apMode ? 0 : WiFi.RSSI(); + doc["freeHeap"] = ESP.getFreeHeap(); + doc["uptime"] = millis() / 1000; + String json; + serializeJson(doc, json); server->send(200, "application/json", json); } @@ -220,6 +229,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function= outputSize) { // JSON output truncated; skip this entry to avoid sending malformed JSON From e2cba5be83fccf4664a774fbb337822dea0262fc Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 30 Dec 2025 22:41:47 +1000 Subject: [PATCH 4/5] Show battery percentage on home screen (#167) ## Summary * Show battery percentage on home screen * Moved battery rendering logic into shared ScreenComponents class * As discussed https://github.com/daveallie/crosspoint-reader/discussions/155 --- src/ScreenComponents.cpp | 41 ++++++++++++++++++++ src/ScreenComponents.h | 8 ++++ src/activities/home/HomeActivity.cpp | 3 ++ src/activities/reader/EpubReaderActivity.cpp | 36 ++--------------- 4 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 src/ScreenComponents.cpp create mode 100644 src/ScreenComponents.h diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp new file mode 100644 index 00000000..2da1a21e --- /dev/null +++ b/src/ScreenComponents.cpp @@ -0,0 +1,41 @@ +#include "ScreenComponents.h" + +#include + +#include + +#include "Battery.h" +#include "fontIds.h" + +void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); + + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 10; + const int x = left; + const int y = top + 7; + + // Top line + renderer.drawLine(x, y, x + batteryWidth - 4, y); + // Bottom line + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y, x, y + batteryHeight - 1); + // Battery end + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; // Ensure we don't overflow + } + + renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); +} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h new file mode 100644 index 00000000..2598a3e3 --- /dev/null +++ b/src/ScreenComponents.h @@ -0,0 +1,8 @@ +#pragma once + +class GfxRenderer; + +class ScreenComponents { + public: + static void drawBattery(const GfxRenderer& renderer, int left, int top); +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 02dc2395..025e2c3f 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -6,6 +6,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ScreenComponents.h" #include "fontIds.h" void HomeActivity::taskTrampoline(void* param) { @@ -315,5 +316,7 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + ScreenComponents::drawBattery(renderer, 20, pageHeight - 30); + renderer.displayBuffer(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c4a6690a..0e38f55a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -5,11 +5,11 @@ #include #include -#include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "MappedInputManager.h" +#include "ScreenComponents.h" #include "fontIds.h" namespace { @@ -422,7 +422,6 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in // Position status bar near the bottom of the logical screen, regardless of orientation const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 2; - int percentageTextWidth = 0; int progressTextWidth = 0; if (showProgress) { @@ -439,42 +438,13 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - // Left aligned battery icon and percentage - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; - percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - renderer.drawText(SMALL_FONT_ID, 20 + orientedMarginLeft, textY, percentageText.c_str()); - - // 1 column on left, 2 columns on right, 5 columns of battery body - constexpr int batteryWidth = 15; - constexpr int batteryHeight = 10; - const int x = orientedMarginLeft; - const int y = screenHeight - orientedMarginBottom + 5; - - // Top line - renderer.drawLine(x, y, x + batteryWidth - 4, y); - // Bottom line - renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); - // Left line - renderer.drawLine(x, y, x, y + batteryHeight - 1); - // Battery end - renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); - renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); - renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); - renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); - - // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; - if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow - } - renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); } if (showChapterTitle) { // Centered chatper title text // Page width minus existing content with 30px padding on each side - const int titleMarginLeft = 20 + percentageTextWidth + 30 + orientedMarginLeft; + const int titleMarginLeft = 50 + 30 + orientedMarginLeft; // 50px for battery const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); From 6a8971fc20ca6ec128bcdf47e70d3b361b3b45da Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 30 Dec 2025 23:42:19 +1100 Subject: [PATCH 5/5] Cut release 0.11.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 142307c4..86cb53ad 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.10.0 +crosspoint_version = 0.11.0 default_envs = default [base]