From 27035b2b91dabd403b8fa13f5101866d0df70e6d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:21:41 +1100 Subject: [PATCH 1/8] Handle 16x16 MCU blocks in JPEG decoding (#120) ## Summary * Handle 16x16 MCU blocks in JPEG decoding * We were only correctly handling 8x8 blocks, which means that we did not correctly support a lot of JPGs leading to an interlacing style on the images ## Additional Context * Fixes https://github.com/daveallie/crosspoint-reader/issues/118 --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b48d70a..c2c049a7 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -182,6 +182,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { } // Process MCU block into MCU row buffer + // MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks: + // Block layout for 16x16 MCU: [0, 64] (top row of blocks) + // [128, 192] (bottom row of blocks) for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { const int pixelX = mcuX * mcuPixelWidth + blockX; @@ -191,16 +194,27 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { continue; } + // Calculate which 8x8 block and position within that block + const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU + const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU + const int pixelInBlockX = blockX % 8; + const int pixelInBlockY = blockY % 8; + + // Calculate byte offset: each 8x8 block is 64 bytes + // Blocks are arranged: [0, 64], [128, 192] + const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64; + const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX; + // Get grayscale value uint8_t gray; if (imageInfo.m_comps == 1) { // Grayscale image - gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + gray = imageInfo.m_pMCUBufR[mcuIndex]; } else { // RGB image - convert to grayscale - const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; - const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; - const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; + const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex]; + const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex]; + const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex]; // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B // Using integer approximation: (30*R + 59*G + 11*B) / 100 gray = (r * 30 + g * 59 + b * 11) / 100; From 2771579007656f78fec756987679abb98cc1ded8 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:17 +1100 Subject: [PATCH 2/8] Add support for blockquote, strong, and em tags (#121) ## Summary * Add support for blockquote, strong, and em tags --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 4a9b86cc..766e5ca6 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -11,13 +11,13 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); -const char* BLOCK_TAGS[] = {"p", "li", "div", "br"}; +const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); -const char* BOLD_TAGS[] = {"b"}; +const char* BOLD_TAGS[] = {"b", "strong"}; constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); -const char* ITALIC_TAGS[] = {"i"}; +const char* ITALIC_TAGS[] = {"i", "em"}; constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; From ea0abaf3513beeb3c21c1105d91182a7a430f3fa Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:21 +1100 Subject: [PATCH 3/8] Prevent SD card error causing boot loop (#122) ## Summary * Prevent SD card error causing boot loop * We need the screen and fonts to be initialized to show the full screen error message * Prior to this change, trying to render the font would crash the firmware and boot loop it --- src/main.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 03f51508..83a33cc7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -158,6 +158,15 @@ void onGoHome() { enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); } +void setupDisplayAndFonts() { + einkDisplay.begin(); + Serial.printf("[%lu] [ ] Display initialized\n", millis()); + renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); + renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); + renderer.insertFont(SMALL_FONT_ID, smallFontFamily); + Serial.printf("[%lu] [ ] Fonts setup\n", millis()); +} + void setup() { t1 = millis(); @@ -179,6 +188,7 @@ void setup() { // SD Card Initialization if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); return; @@ -189,14 +199,7 @@ void setup() { // verify power button press duration after we've read settings. verifyWakeupLongPress(); - // Initialize display - einkDisplay.begin(); - Serial.printf("[%lu] [ ] Display initialized\n", millis()); - - renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); - renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); - renderer.insertFont(SMALL_FONT_ID, smallFontFamily); - Serial.printf("[%lu] [ ] Fonts setup\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new BootActivity(renderer, inputManager)); From b6bc1f7ed365fed9a1fa655fbafacbe43047ff7e Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:36:13 +1100 Subject: [PATCH 4/8] New book.bin spine and table of contents cache (#104) ## Summary * Use single unified cache file for book spine, table of contents, and core metadata (title, author, cover image) * Use new temp item store file in OPF parsing to store items to be rescaned when parsing spine * This avoids us holding these items in memory * Use new toc.bin.tmp and spine.bin.tmp to build out partial toc / spine data as part of parsing content.opf and the NCX file * These files are re-read multiple times to ultimately build book.bin ## Additional Context * Spec for file format included below as an image * This should help with: * #10 * #60 * #99 --- lib/Epub/Epub.cpp | 254 ++++++++------ lib/Epub/Epub.h | 29 +- lib/Epub/Epub/BookMetadataCache.cpp | 326 ++++++++++++++++++ lib/Epub/Epub/BookMetadataCache.h | 87 +++++ lib/Epub/Epub/EpubTocEntry.h | 10 - lib/Epub/Epub/FsHelpers.cpp | 92 +++++ lib/Epub/Epub/FsHelpers.h | 12 + lib/Epub/Epub/Section.cpp | 3 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 60 +++- lib/Epub/Epub/parsers/ContentOpfParser.h | 17 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 8 +- lib/Epub/Epub/parsers/TocNcxParser.h | 12 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- .../EpubReaderChapterSelectionActivity.cpp | 2 +- src/main.cpp | 3 +- 15 files changed, 748 insertions(+), 169 deletions(-) create mode 100644 lib/Epub/Epub/BookMetadataCache.cpp create mode 100644 lib/Epub/Epub/BookMetadataCache.h delete mode 100644 lib/Epub/Epub/EpubTocEntry.h create mode 100644 lib/Epub/Epub/FsHelpers.cpp create mode 100644 lib/Epub/Epub/FsHelpers.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 010f000d..b48d7ea3 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -6,8 +6,6 @@ #include #include -#include - #include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/TocNcxParser.h" @@ -44,7 +42,15 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const { return true; } -bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { +bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { + std::string contentOpfFilePath; + if (!findContentOpfFile(&contentOpfFilePath)) { + Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + return false; + } + + contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); + Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); size_t contentOpfSize; @@ -53,7 +59,9 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { return false; } - ContentOpfParser opfParser(getBasePath(), contentOpfSize); + ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get()); + Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), + ESP.getHeapSize(), ESP.getMinFreeHeap()); if (!opfParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); @@ -66,26 +74,20 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { } // Grab data from opfParser into epub - title = opfParser.title; - if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) { - coverImageItem = opfParser.items.at(opfParser.coverItemId); - } + bookMetadata.title = opfParser.title; + // TODO: Parse author + bookMetadata.author = ""; + bookMetadata.coverItemHref = opfParser.coverItemHref; if (!opfParser.tocNcxPath.empty()) { tocNcxItem = opfParser.tocNcxPath; } - for (auto& spineRef : opfParser.spineRefs) { - if (opfParser.items.count(spineRef)) { - spine.emplace_back(spineRef, opfParser.items.at(spineRef)); - } - } - Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); return true; } -bool Epub::parseTocNcxFile() { +bool Epub::parseTocNcxFile() const { // the ncx file should have been specified in the content.opf file if (tocNcxItem.empty()) { Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); @@ -106,7 +108,7 @@ bool Epub::parseTocNcxFile() { } const auto ncxSize = tempNcxFile.size(); - TocNcxParser ncxParser(contentBasePath, ncxSize); + TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get()); if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); @@ -135,9 +137,7 @@ bool Epub::parseTocNcxFile() { tempNcxFile.close(); SD.remove(tmpNcxPath.c_str()); - this->toc = std::move(ncxParser.toc); - - Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); + Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis()); return true; } @@ -145,48 +145,79 @@ bool Epub::parseTocNcxFile() { bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); - std::string contentOpfFilePath; - if (!findContentOpfFile(&contentOpfFilePath)) { - Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + // Initialize spine/TOC cache + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + + // Try to load existing cache first + if (bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; + } + + // Cache doesn't exist or is invalid, build it + Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); + setupCacheDir(); + + // Begin building cache - stream entries to disk immediately + if (!bookMetadataCache->beginWrite()) { + Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); return false; } - Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str()); - - contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); - - if (!parseContentOpf(contentOpfFilePath)) { + // OPF Pass + BookMetadataCache::BookMetadata bookMetadata; + if (!bookMetadataCache->beginContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis()); + return false; + } + if (!parseContentOpf(bookMetadata)) { Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); return false; } + if (!bookMetadataCache->endContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis()); + return false; + } + // TOC Pass + if (!bookMetadataCache->beginTocPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); + return false; + } if (!parseTocNcxFile()) { Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); return false; } - - initializeSpineItemSizes(); - Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); - - return true; -} - -void Epub::initializeSpineItemSizes() { - Serial.printf("[%lu] [EBP] Calculating book size\n", millis()); - - const size_t spineItemsCount = getSpineItemsCount(); - size_t cumSpineItemSize = 0; - const ZipFile zip("/sd" + filepath); - - for (size_t i = 0; i < spineItemsCount; i++) { - std::string spineItem = getSpineItem(i); - size_t s = 0; - getItemSize(zip, spineItem, &s); - cumSpineItemSize += s; - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); + if (!bookMetadataCache->endTocPass()) { + Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); + return false; } - Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize); + // Close the cache files + if (!bookMetadataCache->endWrite()) { + Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); + return false; + } + + // Build final book.bin + if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) { + Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); + return false; + } + + if (!bookMetadataCache->cleanupTmpFiles()) { + Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis()); + } + + // Reload the cache from disk so it's in the correct state + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + if (!bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); + return false; + } + + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; } bool Epub::clearCache() const { @@ -222,7 +253,14 @@ const std::string& Epub::getCachePath() const { return cachePath; } const std::string& Epub::getPath() const { return filepath; } -const std::string& Epub::getTitle() const { return title; } +const std::string& Epub::getTitle() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + + return bookMetadataCache->coreMetadata.title; +} std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } @@ -232,13 +270,19 @@ bool Epub::generateCoverBmp() const { return true; } - if (coverImageItem.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { Serial.printf("[%lu] [EBP] No known cover image\n", millis()); return false; } - if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || - coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; @@ -246,7 +290,7 @@ bool Epub::generateCoverBmp() const { if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { return false; } - readItemContentsToStream(coverImageItem, coverJpg, 1024); + readItemContentsToStream(coverImageHref, coverJpg, 1024); coverJpg.close(); if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { @@ -276,7 +320,7 @@ bool Epub::generateCoverBmp() const { return false; } -uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { +uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { const ZipFile zip("/sd" + filepath); const std::string path = FsHelpers::normalisePath(itemHref); @@ -306,99 +350,89 @@ bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* return zip.getInflatedFileSize(path.c_str(), size); } -int Epub::getSpineItemsCount() const { return spine.size(); } - -size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= static_cast(cumulativeSpineItemSize.size())) { - Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); +int Epub::getSpineItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { return 0; } - return cumulativeSpineItemSize.at(spineIndex); + return bookMetadataCache->getSpineCount(); } -std::string& Epub::getSpineItem(const int spineIndex) { - static std::string emptyString; - if (spine.empty()) { - Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); - return emptyString; +size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; } + +BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); + return {}; } - if (spineIndex < 0 || spineIndex >= static_cast(spine.size())) { + + if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); - return spine.at(0).second; + return bookMetadataCache->getSpineEntry(0); } - return spine.at(spineIndex).second; + return bookMetadataCache->getSpineEntry(spineIndex); } -EpubTocEntry& Epub::getTocItem(const int tocIndex) { - static EpubTocEntry emptyEntry = {}; - if (toc.empty()) { - Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); - return emptyEntry; +BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); + return {}; } - if (tocIndex < 0 || tocIndex >= static_cast(toc.size())) { + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); - return toc.at(0); + return {}; } - return toc.at(tocIndex); + return bookMetadataCache->getTocEntry(tocIndex); } -int Epub::getTocItemsCount() const { return toc.size(); } +int Epub::getTocItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return 0; + } + + return bookMetadataCache->getTocCount(); +} // work out the section index for a toc index int Epub::getSpineIndexForTocIndex(const int tocIndex) const { - if (tocIndex < 0 || tocIndex >= toc.size()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); + return 0; + } + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); return 0; } - // the toc entry should have an href that matches the spine item - // so we can find the spine index by looking for the href - for (int i = 0; i < spine.size(); i++) { - if (spine[i].second == toc[tocIndex].href) { - return i; - } + const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex; + if (spineIndex < 0) { + Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); + return 0; } - Serial.printf("[%lu] [EBP] Section not found\n", millis()); - // not found - default to the start of the book - return 0; + return spineIndex; } -int Epub::getTocIndexForSpineIndex(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= spine.size()) { - Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex); - return -1; - } - - // the toc entry should have an href that matches the spine item - // so we can find the toc index by looking for the href - for (int i = 0; i < toc.size(); i++) { - if (toc[i].href == spine[spineIndex].second) { - return i; - } - } - - Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); - return -1; -} +int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; } size_t Epub::getBookSize() const { - if (spine.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) { return 0; } return getCumulativeSpineItemSize(getSpineItemsCount() - 1); } // Calculate progress in book -uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) { - size_t bookSize = getBookSize(); +uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { + const size_t bookSize = getBookSize(); if (bookSize == 0) { return 0; } - size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; - size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; - size_t sectionProgSize = currentSpineRead * curChapterSize; + const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; + const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; + const size_t sectionProgSize = currentSpineRead * curChapterSize; return round(static_cast(prevChapterSize + sectionProgSize) / bookSize * 100.0); } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 381379c5..acdd32c8 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -1,38 +1,29 @@ #pragma once -#include +#include #include #include #include -#include "Epub/EpubTocEntry.h" +#include "Epub/BookMetadataCache.h" class ZipFile; class Epub { - // the title read from the EPUB meta data - std::string title; - // the cover image - std::string coverImageItem; // the ncx file std::string tocNcxItem; // where is the EPUBfile? std::string filepath; - // the spine of the EPUB file - std::vector> spine; - // the file size of the spine items (proxy to book progress) - std::vector cumulativeSpineItemSize; - // the toc of the EPUB file - std::vector toc; // the base path for items in the EPUB file std::string contentBasePath; // Uniq cache key based on filepath std::string cachePath; + // Spine and TOC cache + std::unique_ptr bookMetadataCache; bool findContentOpfFile(std::string* contentOpfFile) const; - bool parseContentOpf(const std::string& contentOpfFilePath); - bool parseTocNcxFile(); - void initializeSpineItemSizes(); + bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); + bool parseTocNcxFile() const; static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); public: @@ -54,14 +45,14 @@ class Epub { bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool getItemSize(const std::string& itemHref, size_t* size) const; - std::string& getSpineItem(int spineIndex); + BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const; + BookMetadataCache::TocEntry getTocItem(int tocIndex) const; int getSpineItemsCount() const; - size_t getCumulativeSpineItemSize(const int spineIndex) const; - EpubTocEntry& getTocItem(int tocIndex); int getTocItemsCount() const; int getSpineIndexForTocIndex(int tocIndex) const; int getTocIndexForSpineIndex(int spineIndex) const; + size_t getCumulativeSpineItemSize(int spineIndex) const; size_t getBookSize() const; - uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead); + uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const; }; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp new file mode 100644 index 00000000..3cef851a --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -0,0 +1,326 @@ +#include "BookMetadataCache.h" + +#include +#include +#include +#include + +#include + +#include "FsHelpers.h" + +namespace { +constexpr uint8_t BOOK_CACHE_VERSION = 1; +constexpr char bookBinFile[] = "/book.bin"; +constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; +constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; +} // namespace + +/* ============= WRITING / BUILDING FUNCTIONS ================ */ + +bool BookMetadataCache::beginWrite() { + buildMode = true; + spineCount = 0; + tocCount = 0; + Serial.printf("[%lu] [BMC] Entering write mode\n", millis()); + return true; +} + +bool BookMetadataCache::beginContentOpfPass() { + Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis()); + + // Open spine file for writing + return FsHelpers::openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile); +} + +bool BookMetadataCache::endContentOpfPass() { + spineFile.close(); + return true; +} + +bool BookMetadataCache::beginTocPass() { + Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis()); + + // Open spine file for reading + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + return false; + } + if (!FsHelpers::openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { + spineFile.close(); + return false; + } + return true; +} + +bool BookMetadataCache::endTocPass() { + tocFile.close(); + spineFile.close(); + return true; +} + +bool BookMetadataCache::endWrite() { + if (!buildMode) { + Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis()); + return false; + } + + buildMode = false; + Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) { + // Open all three files, writing to meta, reading from spine and toc + if (!FsHelpers::openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + bookFile.close(); + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { + bookFile.close(); + spineFile.close(); + return false; + } + + constexpr size_t headerASize = + sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(size_t) + sizeof(spineCount) + sizeof(tocCount); + const size_t metadataSize = + metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3; + const size_t lutSize = sizeof(size_t) * spineCount + sizeof(size_t) * tocCount; + const size_t lutOffset = headerASize + metadataSize; + + // Header A + serialization::writePod(bookFile, BOOK_CACHE_VERSION); + serialization::writePod(bookFile, lutOffset); + serialization::writePod(bookFile, spineCount); + serialization::writePod(bookFile, tocCount); + // Metadata + serialization::writeString(bookFile, metadata.title); + serialization::writeString(bookFile, metadata.author); + serialization::writeString(bookFile, metadata.coverItemHref); + + // Loop through spine entries, writing LUT positions + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto pos = spineFile.position(); + auto spineEntry = readSpineEntry(spineFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize); + } + + // Loop through toc entries, writing LUT positions + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto pos = tocFile.position(); + auto tocEntry = readTocEntry(tocFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize + spineFile.position()); + } + + // LUTs complete + // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin + + const ZipFile zip("/sd" + epubPath); + size_t cumSize = 0; + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + + tocFile.seek(0); + for (int j = 0; j < tocCount; j++) { + auto tocEntry = readTocEntry(tocFile); + if (tocEntry.spineIndex == i) { + spineEntry.tocIndex = j; + break; + } + } + + // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs + // Logging here is for debugging + if (spineEntry.tocIndex == -1) { + Serial.printf("[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s\n", millis(), i, + spineEntry.href.c_str()); + } + + // Calculate size for cumulative size + size_t itemSize = 0; + const std::string path = FsHelpers::normalisePath(spineEntry.href); + if (zip.getInflatedFileSize(path.c_str(), &itemSize)) { + cumSize += itemSize; + spineEntry.cumulativeSize = cumSize; + } else { + Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); + } + + // Write out spine data to book.bin + writeSpineEntry(bookFile, spineEntry); + } + + // Loop through toc entries from toc file writing to book.bin + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto tocEntry = readTocEntry(tocFile); + writeTocEntry(bookFile, tocEntry); + } + + bookFile.close(); + spineFile.close(); + tocFile.close(); + + Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis()); + return true; +} + +bool BookMetadataCache::cleanupTmpFiles() const { + if (SD.exists((cachePath + tmpSpineBinFile).c_str())) { + SD.remove((cachePath + tmpSpineBinFile).c_str()); + } + if (SD.exists((cachePath + tmpTocBinFile).c_str())) { + SD.remove((cachePath + tmpTocBinFile).c_str()); + } + return true; +} + +size_t BookMetadataCache::writeSpineEntry(File& file, const SpineEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.href); + serialization::writePod(file, entry.cumulativeSize); + serialization::writePod(file, entry.tocIndex); + return pos; +} + +size_t BookMetadataCache::writeTocEntry(File& file, const TocEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.title); + serialization::writeString(file, entry.href); + serialization::writeString(file, entry.anchor); + serialization::writePod(file, entry.level); + serialization::writePod(file, entry.spineIndex); + return pos; +} + +// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called +// this is because in this function we're marking positions of the items +void BookMetadataCache::createSpineEntry(const std::string& href) { + if (!buildMode || !spineFile) { + Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis()); + return; + } + + const SpineEntry entry(href, 0, -1); + writeSpineEntry(spineFile, entry); + spineCount++; +} + +void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, + const uint8_t level) { + if (!buildMode || !tocFile || !spineFile) { + Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis()); + return; + } + + int spineIndex = -1; + // find spine index + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + if (spineEntry.href == href) { + spineIndex = i; + break; + } + } + + if (spineIndex == -1) { + Serial.printf("[%lu] [BMC] addTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); + } + + const TocEntry entry(title, href, anchor, level, spineIndex); + writeTocEntry(tocFile, entry); + tocCount++; +} + +/* ============= READING / LOADING FUNCTIONS ================ */ + +bool BookMetadataCache::load() { + if (!FsHelpers::openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + uint8_t version; + serialization::readPod(bookFile, version); + if (version != BOOK_CACHE_VERSION) { + Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version); + bookFile.close(); + return false; + } + + serialization::readPod(bookFile, lutOffset); + serialization::readPod(bookFile, spineCount); + serialization::readPod(bookFile, tocCount); + + serialization::readString(bookFile, coreMetadata.title); + serialization::readString(bookFile, coreMetadata.author); + serialization::readString(bookFile, coreMetadata.coverItemHref); + + loaded = true; + Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(spineCount)) { + Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to spine LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * index); + size_t spineEntryPos; + serialization::readPod(bookFile, spineEntryPos); + bookFile.seek(spineEntryPos); + return readSpineEntry(bookFile); +} + +BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(tocCount)) { + Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to TOC LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * spineCount + sizeof(size_t) * index); + size_t tocEntryPos; + serialization::readPod(bookFile, tocEntryPos); + bookFile.seek(tocEntryPos); + return readTocEntry(bookFile); +} + +BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(File& file) const { + SpineEntry entry; + serialization::readString(file, entry.href); + serialization::readPod(file, entry.cumulativeSize); + serialization::readPod(file, entry.tocIndex); + return entry; +} + +BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(File& file) const { + TocEntry entry; + serialization::readString(file, entry.title); + serialization::readString(file, entry.href); + serialization::readString(file, entry.anchor); + serialization::readPod(file, entry.level); + serialization::readPod(file, entry.spineIndex); + return entry; +} diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h new file mode 100644 index 00000000..7f9f419c --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include + +class BookMetadataCache { + public: + struct BookMetadata { + std::string title; + std::string author; + std::string coverItemHref; + }; + + struct SpineEntry { + std::string href; + size_t cumulativeSize; + int16_t tocIndex; + + SpineEntry() : cumulativeSize(0), tocIndex(-1) {} + SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex) + : href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {} + }; + + struct TocEntry { + std::string title; + std::string href; + std::string anchor; + uint8_t level; + int16_t spineIndex; + + TocEntry() : level(0), spineIndex(-1) {} + TocEntry(std::string title, std::string href, std::string anchor, const uint8_t level, const int16_t spineIndex) + : title(std::move(title)), + href(std::move(href)), + anchor(std::move(anchor)), + level(level), + spineIndex(spineIndex) {} + }; + + private: + std::string cachePath; + size_t lutOffset; + uint16_t spineCount; + uint16_t tocCount; + bool loaded; + bool buildMode; + + File bookFile; + // Temp file handles during build + File spineFile; + File tocFile; + + size_t writeSpineEntry(File& file, const SpineEntry& entry) const; + size_t writeTocEntry(File& file, const TocEntry& entry) const; + SpineEntry readSpineEntry(File& file) const; + TocEntry readTocEntry(File& file) const; + + public: + BookMetadata coreMetadata; + + explicit BookMetadataCache(std::string cachePath) + : cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} + ~BookMetadataCache() = default; + + // Building phase (stream to disk immediately) + bool beginWrite(); + bool beginContentOpfPass(); + void createSpineEntry(const std::string& href); + bool endContentOpfPass(); + bool beginTocPass(); + void createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level); + bool endTocPass(); + bool endWrite(); + bool cleanupTmpFiles() const; + + // Post-processing to update mappings and sizes + bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata); + + // Reading phase (read mode) + bool load(); + SpineEntry getSpineEntry(int index); + TocEntry getTocEntry(int index); + int getSpineCount() const { return spineCount; } + int getTocCount() const { return tocCount; } + bool isLoaded() const { return loaded; } +}; diff --git a/lib/Epub/Epub/EpubTocEntry.h b/lib/Epub/Epub/EpubTocEntry.h deleted file mode 100644 index 94f0c90f..00000000 --- a/lib/Epub/Epub/EpubTocEntry.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include - -struct EpubTocEntry { - std::string title; - std::string href; - std::string anchor; - uint8_t level; -}; diff --git a/lib/Epub/Epub/FsHelpers.cpp b/lib/Epub/Epub/FsHelpers.cpp new file mode 100644 index 00000000..743ac59b --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.cpp @@ -0,0 +1,92 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::removeDir(const char* path) { + // 1. Open the directory + File dir = SD.open(path); + if (!dir) { + return false; + } + if (!dir.isDirectory()) { + return false; + } + + File file = dir.openNextFile(); + while (file) { + String filePath = path; + if (!filePath.endsWith("/")) { + filePath += "/"; + } + filePath += file.name(); + + if (file.isDirectory()) { + if (!removeDir(filePath.c_str())) { + return false; + } + } else { + if (!SD.remove(filePath.c_str())) { + return false; + } + } + file = dir.openNextFile(); + } + + return SD.rmdir(path); +} + +std::string FsHelpers::normalisePath(const std::string& path) { + std::vector components; + std::string component; + + for (const auto c : path) { + if (c == '/') { + if (!component.empty()) { + if (component == "..") { + if (!components.empty()) { + components.pop_back(); + } + } else { + components.push_back(component); + } + component.clear(); + } + } else { + component += c; + } + } + + if (!component.empty()) { + components.push_back(component); + } + + std::string result; + for (const auto& c : components) { + if (!result.empty()) { + result += "/"; + } + result += c; + } + + return result; +} diff --git a/lib/Epub/Epub/FsHelpers.h b/lib/Epub/Epub/FsHelpers.h new file mode 100644 index 00000000..193db65f --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.h @@ -0,0 +1,12 @@ +#pragma once +#include + +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ec2993f0..5323a7a5 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -116,8 +116,7 @@ bool Section::clearCache() const { bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - const auto localPath = epub->getSpineItem(spineIndex); - + const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; File tmpHtml; if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 4d3d776f..3cc64014 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -1,11 +1,16 @@ #include "ContentOpfParser.h" +#include #include +#include #include +#include "../BookMetadataCache.h" + namespace { constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; -} +constexpr char itemCacheFile[] = "/.items.bin"; +} // namespace bool ContentOpfParser::setup() { parser = XML_ParserCreate(nullptr); @@ -28,6 +33,12 @@ ContentOpfParser::~ContentOpfParser() { XML_ParserFree(parser); parser = nullptr; } + if (tempItemStore) { + tempItemStore.close(); + } + if (SD.exists((cachePath + itemCacheFile).c_str())) { + SD.remove((cachePath + itemCacheFile).c_str()); + } } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } @@ -94,11 +105,21 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_MANIFEST; + if (!FsHelpers::openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n", + millis()); + } return; } if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { self->state = IN_SPINE; + if (!FsHelpers::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; } @@ -135,7 +156,13 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } } - self->items[itemId] = href; + // Write items down to SD card + serialization::writeString(self->tempItemStore, itemId); + serialization::writeString(self->tempItemStore, href); + + if (itemId == self->coverItemId) { + self->coverItemHref = href; + } if (mediaType == MEDIA_TYPE_NCX) { if (self->tocNcxPath.empty()) { @@ -148,14 +175,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { - for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "idref") == 0) { - self->spineRefs.emplace_back(atts[i + 1]); - break; + // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec) + // Only run the spine parsing if there's a cache to add it to + if (self->cache) { + if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "idref") == 0) { + const std::string idref = atts[i + 1]; + // Resolve the idref to href using items map + self->tempItemStore.seek(0); + std::string itemId; + std::string href; + while (self->tempItemStore.available()) { + serialization::readString(self->tempItemStore, itemId); + serialization::readString(self->tempItemStore, href); + if (itemId == idref) { + self->cache->createSpineEntry(href); + break; + } + } + } } + return; } - return; } } @@ -174,11 +216,13 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 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(); return; } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index a3070fcc..5415de67 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -1,11 +1,11 @@ #pragma once #include -#include - #include "Epub.h" #include "expat.h" +class BookMetadataCache; + class ContentOpfParser final : public Print { enum ParserState { START, @@ -16,10 +16,14 @@ class ContentOpfParser final : public Print { IN_SPINE, }; + const std::string& cachePath; const std::string& baseContentPath; size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; + File tempItemStore; + std::string coverItemId; static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void characterData(void* userData, const XML_Char* s, int len); @@ -28,12 +32,11 @@ class ContentOpfParser final : public Print { public: std::string title; std::string tocNcxPath; - std::string coverItemId; - std::map items; - std::vector spineRefs; + std::string coverItemHref; - explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize, + BookMetadataCache* cache) + : cachePath(cachePath), baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~ContentOpfParser() override; bool setup(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index f4700558..b1fbb2fe 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,8 +1,9 @@ #include "TocNcxParser.h" -#include #include +#include "../BookMetadataCache.h" + bool TocNcxParser::setup() { parser = XML_ParserCreate(nullptr); if (!parser) { @@ -167,8 +168,9 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { href = href.substr(0, pos); } - // Push to vector - self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); + if (self->cache) { + self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth); + } // Clear them so we don't re-add them if there are weird XML structures self->currentLabel.clear(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 2f3601a1..e2c86205 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -1,11 +1,10 @@ #pragma once #include +#include #include -#include -#include "Epub/EpubTocEntry.h" -#include "expat.h" +class BookMetadataCache; class TocNcxParser final : public Print { enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT }; @@ -14,6 +13,7 @@ class TocNcxParser final : public Print { size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; std::string currentLabel; std::string currentSrc; @@ -24,10 +24,8 @@ class TocNcxParser final : public Print { static void endElement(void* userData, const XML_Char* name); public: - std::vector toc; - - explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache) + : baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~TocNcxParser() override; bool setup(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7843f5bb..6195ec22 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -212,7 +212,7 @@ void EpubReaderActivity::renderScreen() { } if (!section) { - const auto filepath = epub->getSpineItem(currentSpineIndex); + const auto filepath = epub->getSpineItem(currentSpineIndex).href; Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1cda06ea..3754fa04 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -29,7 +29,7 @@ void EpubReaderChapterSelectionActivity::onEnter() { // Trigger first update updateRequired = true; xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle diff --git a/src/main.cpp b/src/main.cpp index 83a33cc7..b71ea399 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -186,7 +186,8 @@ void setup() { SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); // SD Card Initialization - if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { + // We need 6 open files concurrently when parsing a new chapter + if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ, "/sd", 6)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); setupDisplayAndFonts(); exitActivity(); From 504c7b307d8cb0ec7d54cdca93c720f04464d323 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 21:49:47 +1000 Subject: [PATCH 5/8] Cut release 0.9.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 42de32c0..9cd5df2e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.8.1 +crosspoint_version = 0.9.0 default_envs = default [base] From dc7544d9446249fea05f342ccc3b1df29b789bb0 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 26 Dec 2025 09:46:17 +0900 Subject: [PATCH 6/8] Optimize glyph lookup with binary search (#125) Replace linear O(n) search with binary search O(log n) for unicode interval lookup. Korean fonts have many intervals (~30,000+ glyphs), so this improves text rendering performance during page navigation. ## Summary * **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for file uploading.) Replace linear `O(n)` glyph lookup with binary search `O(log n)` to improve text rendering performance during page navigation. * **What changes are included?** - Modified `EpdFont::getGlyph()` to use binary search instead of linear search for unicode interval lookup - Added early return for empty interval count ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). - Performance implications: Fonts with many unicode intervals benefit the most. Korean fonts have ~30,000+ glyphs across multiple intervals, but any font with significant glyph coverage (CJK, extended Latin, emoji, etc.) will see improvement. - Complexity: from `O(n)` to `O(log n)` where n = number of unicode intervals. For fonts with 10+ intervals, this reduces lookup iterations significantly. - Risk: Low - the binary search logic is straightforward and the intervals are already sorted by unicode codepoint (required for the original early-exit optimization). --- lib/EpdFont/EpdFont.cpp | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 0f53f95b..661317d6 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -59,14 +59,28 @@ bool EpdFont::hasPrintableChars(const char* string) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const EpdUnicodeInterval* intervals = data->intervals; - for (int i = 0; i < data->intervalCount; i++) { - const EpdUnicodeInterval* interval = &intervals[i]; - if (cp >= interval->first && cp <= interval->last) { + const int count = data->intervalCount; + + if (count == 0) return nullptr; + + // Binary search for O(log n) lookup instead of O(n) + // Critical for Korean fonts with many unicode intervals + int left = 0; + int right = count - 1; + + while (left <= right) { + const int mid = left + (right - left) / 2; + const EpdUnicodeInterval* interval = &intervals[mid]; + + if (cp < interval->first) { + right = mid - 1; + } else if (cp > interval->last) { + left = mid + 1; + } else { + // Found: cp >= interval->first && cp <= interval->last return &data->glyph[interval->offset + (cp - interval->first)]; } - if (cp < interval->first) { - return nullptr; - } } + return nullptr; } From e3c1e28b8fff140be0977106485cf1bf05575d8c Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Thu, 25 Dec 2025 19:54:02 -0500 Subject: [PATCH 7/8] Normalize button hints (#130) ## Summary This creates a `renderer.drawButtonHints` to make all of the "hints" over buttons to match the home screen. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --------- Co-authored-by: Dave Allie --- lib/GfxRenderer/GfxRenderer.cpp | 22 +++++++++++++++++++ lib/GfxRenderer/GfxRenderer.h | 3 +++ src/activities/home/HomeActivity.cpp | 14 +----------- .../network/CrossPointWebServerActivity.cpp | 4 +--- .../network/NetworkModeSelectionActivity.cpp | 2 +- .../network/WifiSelectionActivity.cpp | 5 +++-- .../reader/FileSelectionActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 2 +- 8 files changed, 33 insertions(+), 21 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a4b9369b..6433748e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -239,6 +239,28 @@ int GfxRenderer::getLineHeight(const int fontId) const { return fontMap.at(fontId).getData(REGULAR)->advanceY; } +void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + const int pageHeight = getScreenHeight(); + constexpr int buttonWidth = 106; + constexpr int buttonHeight = 40; + constexpr int buttonY = 40; // Distance from bottom + constexpr int textYOffset = 5; // Distance from top of button to text baseline + constexpr int buttonPositions[] = {25, 130, 245, 350}; + const char* labels[] = {btn1, btn2, btn3, btn4}; + + for (int i = 0; i < 4; i++) { + // Only draw if the label is non-empty + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int x = buttonPositions[i]; + drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); + const int textWidth = getTextWidth(fontId, labels[i]); + const int textX = x + (buttonWidth - 1 - textWidth) / 2; + drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); + } + } +} + uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 838e0180..00a525dd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -57,6 +57,9 @@ class GfxRenderer { int getSpaceWidth(int fontId) const; int getLineHeight(int fontId) const; + // UI Components + void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; + // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void copyGrayscaleLsbBuffers() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index bbda1307..68e64b33 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -85,7 +85,6 @@ void HomeActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); // Draw selection @@ -94,18 +93,7 @@ void HomeActivity::render() const { renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1); renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2); - renderer.drawRect(25, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back"); - - renderer.drawRect(130, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35, - "Confirm"); - - renderer.drawRect(245, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left"); - - renderer.drawRect(350, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right"); + renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right"); renderer.displayBuffer(); } diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index b9c911e2..34b1a3a8 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -337,8 +337,6 @@ void CrossPointWebServerActivity::render() const { } void CrossPointWebServerActivity::renderServerRunning() const { - const auto pageHeight = renderer.getScreenHeight(); - // Use consistent line spacing constexpr int LINE_SPACING = 28; // Space between lines @@ -389,5 +387,5 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); + renderer.drawButtonHints(UI_FONT_ID, "« Exit", "", "", ""); } diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index 637d82d9..af68a20b 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -122,7 +122,7 @@ void NetworkModeSelectionActivity::render() const { } // Draw help text at bottom - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR); + renderer.drawButtonHints(UI_FONT_ID, "« Back", "Select", "", ""); renderer.displayBuffer(); } diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index c18e0f57..80e46ceb 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -548,11 +548,12 @@ void WifiSelectionActivity::renderNetworkList() const { // Show network count char countStr[32]; snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr); } // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved"); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); + renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); } void WifiSelectionActivity::renderPasswordEntry() const { diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index a6c10834..d6504b84 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -149,7 +149,7 @@ void FileSelectionActivity::render() const { renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text - renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); + renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); if (files.empty()) { renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 37f2e5a1..f7af052e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -169,7 +169,7 @@ void SettingsActivity::render() const { } // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawButtonHints(UI_FONT_ID, "« Save", "Toggle", "", ""); renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), pageHeight - 30, CROSSPOINT_VERSION); From b77af16caa3e1c668d57fd6dec2aaa06b9e223f0 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 26 Dec 2025 09:55:23 +0900 Subject: [PATCH 8/8] Add Continue Reading menu and remember last book folder (#129) ## Summary * **What is the goal of this PR?** Add a "Continue Reading" feature to improve user experience when returning to a previously opened book. * **What changes are included?** - Add dynamic "Continue: " menu item in Home screen when a book was previously opened - File browser now starts from the folder of the last opened book instead of always starting from root directory - Menu dynamically shows 3 or 4 items based on reading history: - Without history: `Browse`, `File transfer`, `Settings` - With history: `Continue: `, `Browse`, `File transfer`, `Settings` ## Additional Context * This feature leverages the existing `APP_STATE.openEpubPath` which already persists the last opened book path * The Continue Reading menu only appears if the book file still exists on the SD card * Book name in the menu is truncated to 25 characters with "..." suffix if too long * If the last book's folder was deleted, the file browser gracefully falls back to root directory * No new dependencies or significant memory overhead - reuses existing state management --- src/activities/home/HomeActivity.cpp | 80 +++++++++++++++---- src/activities/home/HomeActivity.h | 7 +- src/activities/reader/EpubReaderActivity.cpp | 10 ++- src/activities/reader/EpubReaderActivity.h | 8 +- .../reader/FileSelectionActivity.cpp | 33 +++++--- src/activities/reader/FileSelectionActivity.h | 7 +- src/activities/reader/ReaderActivity.cpp | 24 +++++- src/activities/reader/ReaderActivity.h | 4 +- src/main.cpp | 4 +- 9 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 68e64b33..38dc8542 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,22 +4,24 @@ #include #include +#include "CrossPointState.h" #include "config.h" -namespace { -constexpr int menuItemCount = 3; -} - void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } +int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } + void HomeActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); + // Check if we have a book to continue reading + hasContinueReading = !APP_STATE.openEpubPath.empty() && SD.exists(APP_STATE.openEpubPath.c_str()); + selectorIndex = 0; // Trigger first update @@ -52,19 +54,35 @@ void HomeActivity::loop() { const bool nextPressed = inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + const int menuCount = getMenuItemCount(); + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { - if (selectorIndex == 0) { - onReaderOpen(); - } else if (selectorIndex == 1) { - onFileTransferOpen(); - } else if (selectorIndex == 2) { - onSettingsOpen(); + if (hasContinueReading) { + // Menu: Continue Reading, Browse, File transfer, Settings + if (selectorIndex == 0) { + onContinueReading(); + } else if (selectorIndex == 1) { + onReaderOpen(); + } else if (selectorIndex == 2) { + onFileTransferOpen(); + } else if (selectorIndex == 3) { + onSettingsOpen(); + } + } else { + // Menu: Browse, File transfer, Settings + if (selectorIndex == 0) { + onReaderOpen(); + } else if (selectorIndex == 1) { + onFileTransferOpen(); + } else if (selectorIndex == 2) { + onSettingsOpen(); + } } } else if (prevPressed) { - selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount; + selectorIndex = (selectorIndex + menuCount - 1) % menuCount; updateRequired = true; } else if (nextPressed) { - selectorIndex = (selectorIndex + 1) % menuItemCount; + selectorIndex = (selectorIndex + 1) % menuCount; updateRequired = true; } } @@ -89,9 +107,41 @@ void HomeActivity::render() const { // Draw selection renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); - renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0); - renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1); - renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2); + + int menuY = 60; + int menuIndex = 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); + } + // Truncate if too long + if (bookName.length() > 25) { + bookName.resize(22); + bookName += "..."; + } + std::string continueLabel = "Continue: " + bookName; + renderer.drawText(UI_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + } + + renderer.drawText(UI_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + + renderer.drawText(UI_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + + renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex); renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right"); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 943a4665..0704819c 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -12,6 +12,8 @@ class HomeActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; int selectorIndex = 0; bool updateRequired = false; + bool hasContinueReading = false; + const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; @@ -19,11 +21,14 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; + int getMenuItemCount() const; public: - explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onReaderOpen, + explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onContinueReading, const std::function& onReaderOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen) : Activity("Home", renderer, inputManager), + onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen) {} diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6195ec22..f4905d60 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -14,6 +14,7 @@ namespace { constexpr int pagesPerRefresh = 15; constexpr unsigned long skipChapterMs = 700; +constexpr unsigned long goHomeMs = 1000; constexpr float lineCompression = 0.95f; constexpr int marginTop = 8; constexpr int marginRight = 10; @@ -108,7 +109,14 @@ void EpubReaderActivity::loop() { xSemaphoreGive(renderingMutex); } - if (inputManager.wasPressed(InputManager::BTN_BACK)) { + // Long press BACK (1s+) goes directly to home + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { onGoBack(); return; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 4edbabc2..143f56b1 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -17,6 +17,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int pagesUntilFullRefresh = 0; bool updateRequired = false; const std::function onGoBack; + const std::function onGoHome; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -26,8 +27,11 @@ class EpubReaderActivity final : public ActivityWithSubactivity { public: explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr epub, - const std::function& onGoBack) - : ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("EpubReader", renderer, inputManager), + epub(std::move(epub)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index d6504b84..853b06f1 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -9,6 +9,7 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr int SKIP_PAGE_MS = 700; +constexpr unsigned long GO_HOME_MS = 1000; } // namespace void sortFileList(std::vector& strs) { @@ -53,7 +54,7 @@ void FileSelectionActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - basepath = "/"; + // basepath is set via constructor parameter (defaults to "/" if not specified) loadFiles(); selectorIndex = 0; @@ -83,6 +84,16 @@ void FileSelectionActivity::onExit() { } void FileSelectionActivity::loop() { + // Long press BACK (1s+) goes to root folder + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= GO_HOME_MS) { + if (basepath != "/") { + basepath = "/"; + loadFiles(); + updateRequired = true; + } + return; + } + const bool prevReleased = inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); const bool nextReleased = @@ -103,15 +114,17 @@ void FileSelectionActivity::loop() { } else { onSelect(basepath + files[selectorIndex]); } - } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { - if (basepath != "/") { - basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); - if (basepath.empty()) basepath = "/"; - loadFiles(); - updateRequired = true; - } else { - // At root level, go back home - onGoHome(); + } else if (inputManager.wasReleased(InputManager::BTN_BACK)) { + // Short press: go up one directory, or go home if at root + if (inputManager.getHeldTime() < GO_HOME_MS) { + if (basepath != "/") { + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); + if (basepath.empty()) basepath = "/"; + loadFiles(); + updateRequired = true; + } else { + onGoHome(); + } } } else if (prevReleased) { if (skipPage) { diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 2a8f8ae1..f642e209 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -27,8 +27,11 @@ class FileSelectionActivity final : public Activity { public: explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onSelect, - const std::function& onGoHome) - : Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} + const std::function& onGoHome, std::string initialPath = "/") + : Activity("FileSelection", renderer, inputManager), + basepath(initialPath.empty() ? "/" : std::move(initialPath)), + onSelect(onSelect), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index d888fb6e..519a33a2 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -7,6 +7,14 @@ #include "FileSelectionActivity.h" #include "activities/util/FullScreenMessageActivity.h" +std::string ReaderActivity::extractFolderPath(const std::string& filePath) { + const auto lastSlash = filePath.find_last_of('/'); + if (lastSlash == std::string::npos || lastSlash == 0) { + return "/"; + } + return filePath.substr(0, lastSlash); +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SD.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -23,6 +31,7 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { } void ReaderActivity::onSelectEpubFile(const std::string& path) { + currentEpubPath = path; // Track current book path exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); @@ -38,25 +47,32 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) { } } -void ReaderActivity::onGoToFileSelection() { +void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { exitActivity(); + // If coming from a book, start in that book's folder; otherwise start from root + const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack)); + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { + const auto epubPath = epub->getPath(); + currentEpubPath = epubPath; exitActivity(); - enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); })); + enterNewActivity(new EpubReaderActivity( + renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); }, + [this] { onGoBack(); })); } void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); if (initialEpubPath.empty()) { - onGoToFileSelection(); + onGoToFileSelection(); // Start from root when entering via Browse return; } + currentEpubPath = initialEpubPath; auto epub = loadEpub(initialEpubPath); if (!epub) { onGoBack(); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index e566d6d3..5bb34193 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -7,11 +7,13 @@ class Epub; class ReaderActivity final : public ActivityWithSubactivity { std::string initialEpubPath; + std::string currentEpubPath; // Track current book path for navigation const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); + static std::string extractFolderPath(const std::string& filePath); void onSelectEpubFile(const std::string& path); - void onGoToFileSelection(); + void onGoToFileSelection(const std::string& fromEpubPath = ""); void onGoToEpubReader(std::unique_ptr epub); public: diff --git a/src/main.cpp b/src/main.cpp index b71ea399..9b950f1f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -142,6 +142,7 @@ void onGoToReader(const std::string& initialEpubPath) { enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome)); } void onGoToReaderHome() { onGoToReader(std::string()); } +void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); } void onGoToFileTransfer() { exitActivity(); @@ -155,7 +156,8 @@ void onGoToSettings() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); + enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, onGoToReaderHome, onGoToSettings, + onGoToFileTransfer)); } void setupDisplayAndFonts() {