diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1d91ec..be9a6e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: actions/cache@v5 - with: - path: | - ~/.cache/pip - ~/.platformio/.cache - key: ${{ runner.os }}-pio - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 0f53f95..661317d 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; } diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index d959cb7..b48d7ea 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,13 +1,11 @@ #include "Epub.h" +#include #include #include #include #include -#include - -#include "Epub/FsHelpers.h" #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()); @@ -95,13 +97,18 @@ bool Epub::parseTocNcxFile() { Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); const auto tmpNcxPath = getCachePath() + "/toc.ncx"; - File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); + File tempNcxFile; + if (!FsHelpers::openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); tempNcxFile.close(); - tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); + if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } 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()); @@ -130,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; } @@ -140,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 { @@ -217,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"; } @@ -227,24 +270,42 @@ 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()); - File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); - readItemContentsToStream(coverImageItem, coverJpg, 1024); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + File coverJpg; + if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); coverJpg.close(); - coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); - File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + File coverBmp; + if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); coverJpg.close(); coverBmp.close(); - SD.remove((getCachePath() + "/.cover.jpg").c_str()); + SD.remove(coverJpgTempPath.c_str()); if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); @@ -259,45 +320,9 @@ bool Epub::generateCoverBmp() const { return false; } -std::string 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; -} - -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 = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte); if (!content) { @@ -310,7 +335,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); return zip.readFileToStream(path.c_str(), out, chunkSize); } @@ -321,103 +346,93 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { } bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); 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 tocTndex) { - static EpubTocEntry emptyEntry = {}; - if (toc.empty()) { - Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); - return emptyEntry; - } - if (tocTndex < 0 || tocTndex >= static_cast(toc.size())) { - Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); - return toc.at(0); +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 {}; } - return toc.at(tocTndex); + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { + Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); + return {}; + } + + 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 381379c..acdd32c 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 0000000..3cef851 --- /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 0000000..7f9f419 --- /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 94f0c90..0000000 --- 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 index 5287252..743ac59 100644 --- a/lib/Epub/Epub/FsHelpers.cpp +++ b/lib/Epub/Epub/FsHelpers.cpp @@ -2,6 +2,26 @@ #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); @@ -34,3 +54,39 @@ bool FsHelpers::removeDir(const char* path) { 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 index bc5204b..193db65 100644 --- a/lib/Epub/Epub/FsHelpers.h +++ b/lib/Epub/Epub/FsHelpers.h @@ -1,6 +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/Page.cpp b/lib/Epub/Epub/Page.cpp index 01bb3ac..b41dd3c 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -9,21 +9,21 @@ constexpr uint8_t PAGE_FILE_VERSION = 3; void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } -void PageLine::serialize(std::ostream& os) { - serialization::writePod(os, xPos); - serialization::writePod(os, yPos); +void PageLine::serialize(File& file) { + serialization::writePod(file, xPos); + serialization::writePod(file, yPos); // serialize TextBlock pointed to by PageLine - block->serialize(os); + block->serialize(file); } -std::unique_ptr PageLine::deserialize(std::istream& is) { +std::unique_ptr PageLine::deserialize(File& file) { int16_t xPos; int16_t yPos; - serialization::readPod(is, xPos); - serialization::readPod(is, yPos); + serialization::readPod(file, xPos); + serialization::readPod(file, yPos); - auto tb = TextBlock::deserialize(is); + auto tb = TextBlock::deserialize(file); return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } @@ -33,22 +33,22 @@ void Page::render(GfxRenderer& renderer, const int fontId) const { } } -void Page::serialize(std::ostream& os) const { - serialization::writePod(os, PAGE_FILE_VERSION); +void Page::serialize(File& file) const { + serialization::writePod(file, PAGE_FILE_VERSION); const uint32_t count = elements.size(); - serialization::writePod(os, count); + serialization::writePod(file, count); for (const auto& el : elements) { // Only PageLine exists currently - serialization::writePod(os, static_cast(TAG_PageLine)); - el->serialize(os); + serialization::writePod(file, static_cast(TAG_PageLine)); + el->serialize(file); } } -std::unique_ptr Page::deserialize(std::istream& is) { +std::unique_ptr Page::deserialize(File& file) { uint8_t version; - serialization::readPod(is, version); + serialization::readPod(file, version); if (version != PAGE_FILE_VERSION) { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version); return nullptr; @@ -57,14 +57,14 @@ std::unique_ptr Page::deserialize(std::istream& is) { auto page = std::unique_ptr(new Page()); uint32_t count; - serialization::readPod(is, count); + serialization::readPod(file, count); for (uint32_t i = 0; i < count; i++) { uint8_t tag; - serialization::readPod(is, tag); + serialization::readPod(file, tag); if (tag == TAG_PageLine) { - auto pl = PageLine::deserialize(is); + auto pl = PageLine::deserialize(file); page->elements.push_back(std::move(pl)); } else { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 59333ce..1026653 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,4 +1,6 @@ #pragma once +#include + #include #include @@ -16,7 +18,7 @@ class PageElement { explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} virtual ~PageElement() = default; virtual void render(GfxRenderer& renderer, int fontId) = 0; - virtual void serialize(std::ostream& os) = 0; + virtual void serialize(File& file) = 0; }; // a line from a block element @@ -27,8 +29,8 @@ class PageLine final : public PageElement { PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} void render(GfxRenderer& renderer, int fontId) override; - void serialize(std::ostream& os) override; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) override; + static std::unique_ptr deserialize(File& file); }; class Page { @@ -36,6 +38,6 @@ class Page { // the list of block index and line numbers on this page std::vector> elements; void render(GfxRenderer& renderer, int fontId) const; - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7c9d241..5323a7a 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -1,11 +1,9 @@ #include "Section.h" +#include #include #include -#include - -#include "FsHelpers.h" #include "Page.h" #include "parsers/ChapterHtmlSlimParser.h" @@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 5; void Section::onPageComplete(std::unique_ptr page) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; - std::ofstream outputFile("/sd" + filePath); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", filePath, outputFile)) { + return; + } page->serialize(outputFile); outputFile.close(); @@ -28,7 +29,10 @@ void Section::onPageComplete(std::unique_ptr page) { void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) const { - std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { + return; + } serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, lineCompression); @@ -44,17 +48,12 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - if (!SD.exists(cachePath.c_str())) { - return false; - } - const auto sectionFilePath = cachePath + "/section.bin"; - if (!SD.exists(sectionFilePath.c_str())) { + File inputFile; + if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) { return false; } - std::ifstream inputFile(("/sd" + sectionFilePath).c_str()); - // Match parameters { uint8_t version; @@ -117,15 +116,14 @@ 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); - - // TODO: Should we get rid of this file all together? - // It currently saves us a bit of memory by allowing for all the inflation bits to be released - // before loading the XML parser + const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; - File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); - bool success = epub->readItemContentsToStream(localPath, f, 1024); - f.close(); + File tmpHtml; + if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + return false; + } + bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); + tmpHtml.close(); if (!success) { Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); @@ -134,10 +132,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); - const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; - - ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, - marginBottom, marginLeft, extraParagraphSpacing, + ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, + marginLeft, extraParagraphSpacing, [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); success = visitor.parseAndBuildPages(); @@ -153,13 +149,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, } std::unique_ptr Section::loadPageFromSD() const { - const auto filePath = "/sd" + cachePath + "/page_" + std::to_string(currentPage) + ".bin"; - if (!SD.exists(filePath.c_str() + 3)) { - Serial.printf("[%lu] [SCT] Page file does not exist: %s\n", millis(), filePath.c_str()); + const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin"; + + File inputFile; + if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) { return nullptr; } - - std::ifstream inputFile(filePath); auto page = Page::deserialize(inputFile); inputFile.close(); return page; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index cc3cb60..bb8b14e 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -17,27 +17,27 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } } -void TextBlock::serialize(std::ostream& os) const { +void TextBlock::serialize(File& file) const { // words const uint32_t wc = words.size(); - serialization::writePod(os, wc); - for (const auto& w : words) serialization::writeString(os, w); + serialization::writePod(file, wc); + for (const auto& w : words) serialization::writeString(file, w); // wordXpos const uint32_t xc = wordXpos.size(); - serialization::writePod(os, xc); - for (auto x : wordXpos) serialization::writePod(os, x); + serialization::writePod(file, xc); + for (auto x : wordXpos) serialization::writePod(file, x); // wordStyles const uint32_t sc = wordStyles.size(); - serialization::writePod(os, sc); - for (auto s : wordStyles) serialization::writePod(os, s); + serialization::writePod(file, sc); + for (auto s : wordStyles) serialization::writePod(file, s); // style - serialization::writePod(os, style); + serialization::writePod(file, style); } -std::unique_ptr TextBlock::deserialize(std::istream& is) { +std::unique_ptr TextBlock::deserialize(File& file) { uint32_t wc, xc, sc; std::list words; std::list wordXpos; @@ -45,22 +45,22 @@ std::unique_ptr TextBlock::deserialize(std::istream& is) { BLOCK_STYLE style; // words - serialization::readPod(is, wc); + serialization::readPod(file, wc); words.resize(wc); - for (auto& w : words) serialization::readString(is, w); + for (auto& w : words) serialization::readString(file, w); // wordXpos - serialization::readPod(is, xc); + serialization::readPod(file, xc); wordXpos.resize(xc); - for (auto& x : wordXpos) serialization::readPod(is, x); + for (auto& x : wordXpos) serialization::readPod(file, x); // wordStyles - serialization::readPod(is, sc); + serialization::readPod(file, sc); wordStyles.resize(sc); - for (auto& s : wordStyles) serialization::readPod(is, s); + for (auto& s : wordStyles) serialization::readPod(file, s); // style - serialization::readPod(is, style); + serialization::readPod(file, style); return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 4b2b031..46e320e 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -35,6 +36,6 @@ class TextBlock final : public Block { // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index a629707..766e5ca 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,5 +1,6 @@ #include "ChapterHtmlSlimParser.h" +#include #include #include #include @@ -10,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"}; @@ -214,9 +215,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - FILE* file = fopen(filepath, "r"); - if (!file) { - Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); + File file; + if (!FsHelpers::openFileForRead("EHP", filepath, file)) { XML_ParserFree(parser); return false; } @@ -233,23 +233,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - const size_t len = fread(buf, 1, 1024, file); + const size_t len = file.read(static_cast(buf), 1024); - if (ferror(file)) { + if (len == 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 XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - done = feof(file); + done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), @@ -258,7 +258,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } } while (!done); @@ -267,7 +267,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); // Process last page if there is still text if (currentTextBlock) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index f656b4a..7f74602 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -15,7 +15,7 @@ class GfxRenderer; #define MAX_WORD_SIZE 200 class ChapterHtmlSlimParser { - const char* filepath; + const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; int depth = 0; @@ -45,7 +45,7 @@ class ChapterHtmlSlimParser { static void XMLCALL endElement(void* userData, const XML_Char* name); public: - explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, + explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const std::function)>& completePageFn) diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 4d3d776..3cc6401 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 a3070fc..5415de6 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 f470055..b1fbb2f 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 2f3601a..e2c8620 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/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp new file mode 100644 index 0000000..06f3dfe --- /dev/null +++ b/lib/FsHelpers/FsHelpers.cpp @@ -0,0 +1,112 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const char* path, File& file) { + if (!SD.exists(path)) { + return false; + } + + file = SD.open(path, FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForRead(const char* moduleName, const String& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const char* path, File& file) { + file = SD.open(path, FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const String& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +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/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h new file mode 100644 index 0000000..0dff145 --- /dev/null +++ b/lib/FsHelpers/FsHelpers.h @@ -0,0 +1,14 @@ +#pragma once +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const char* path, File& file); + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForRead(const char* moduleName, const String& path, File& file); + static bool openFileForWrite(const char* moduleName, const char* path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const String& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a4b9369..6433748 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 838e018..00a525d 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/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b48d70..c2c049a 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; diff --git a/lib/Serialization/Serialization.h b/lib/Serialization/Serialization.h index 20eb4a4..e6bcbf2 100644 --- a/lib/Serialization/Serialization.h +++ b/lib/Serialization/Serialization.h @@ -1,4 +1,6 @@ #pragma once +#include + #include namespace serialization { @@ -7,21 +9,44 @@ static void writePod(std::ostream& os, const T& value) { os.write(reinterpret_cast(&value), sizeof(T)); } +template +static void writePod(File& file, const T& value) { + file.write(reinterpret_cast(&value), sizeof(T)); +} + template static void readPod(std::istream& is, T& value) { is.read(reinterpret_cast(&value), sizeof(T)); } +template +static void readPod(File& file, T& value) { + file.read(reinterpret_cast(&value), sizeof(T)); +} + static void writeString(std::ostream& os, const std::string& s) { const uint32_t len = s.size(); writePod(os, len); os.write(s.data(), len); } +static void writeString(File& file, const std::string& s) { + const uint32_t len = s.size(); + writePod(file, len); + file.write(reinterpret_cast(s.data()), len); +} + static void readString(std::istream& is, std::string& s) { uint32_t len; readPod(is, len); s.resize(len); is.read(&s[0], len); } + +static void readString(File& file, std::string& s) { + uint32_t len; + readPod(file, len); + s.resize(len); + file.read(reinterpret_cast(&s[0]), len); +} } // namespace serialization diff --git a/platformio.ini b/platformio.ini index a44dd47..0fd766a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,9 +1,9 @@ [platformio] -crosspoint_version = 0.8.1 +crosspoint_version = 0.9.0 default_envs = default [base] -platform = espressif32 +platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 framework = arduino monitor_speed = 115200 @@ -40,6 +40,7 @@ lib_deps = InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay ArduinoJson @ 7.4.2 + QRCode @ 0.0.1 [env:default] extends = base diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 69fb243..83ba59d 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -1,12 +1,10 @@ #include "CrossPointSettings.h" +#include #include #include #include -#include -#include - // Initialize the static instance CrossPointSettings CrossPointSettings::instance; @@ -20,7 +18,11 @@ bool CrossPointSettings::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream outputFile(SETTINGS_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", SETTINGS_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); serialization::writePod(outputFile, sleepScreen); @@ -34,13 +36,11 @@ bool CrossPointSettings::saveToFile() const { } bool CrossPointSettings::loadFromFile() { - if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis()); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) { return false; } - std::ifstream inputFile(SETTINGS_FILE); - uint8_t version; serialization::readPod(inputFile, version); if (version != SETTINGS_FILE_VERSION) { diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index dd96593..9010822 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -1,20 +1,22 @@ #include "CrossPointState.h" +#include #include -#include #include -#include - namespace { constexpr uint8_t STATE_FILE_VERSION = 1; -constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; +constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace CrossPointState CrossPointState::instance; bool CrossPointState::saveToFile() const { - std::ofstream outputFile(STATE_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", STATE_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); outputFile.close(); @@ -22,7 +24,10 @@ bool CrossPointState::saveToFile() const { } bool CrossPointState::loadFromFile() { - std::ifstream inputFile(STATE_FILE); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) { + return false; + } uint8_t version; serialization::readPod(inputFile, version); diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 7df9e2f..856098f 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -1,11 +1,10 @@ #include "WifiCredentialStore.h" +#include #include #include #include -#include - // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; @@ -14,7 +13,7 @@ namespace { constexpr uint8_t WIFI_FILE_VERSION = 1; // WiFi credentials file path -constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; +constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; // Obfuscation key - "CrossPoint" in ASCII // This is NOT cryptographic security, just prevents casual file reading @@ -33,9 +32,8 @@ bool WifiCredentialStore::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis()); + File file; + if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) { return false; } @@ -62,14 +60,8 @@ bool WifiCredentialStore::saveToFile() const { } bool WifiCredentialStore::loadFromFile() { - if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); - return false; - } - - std::ifstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis()); + File file; + if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) { return false; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index ca72aeb..4bc70f5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,6 +1,7 @@ #include "SleepActivity.h" #include +#include #include #include @@ -76,8 +77,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Generate a random number between 1 and numFiles const auto randomFileIndex = random(numFiles); const auto filename = "/sleep/" + files[randomFileIndex]; - auto file = SD.open(filename.c_str()); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file); @@ -93,8 +94,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. - auto file = SD.open("/sleep.bmp"); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); @@ -186,8 +187,8 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index bbda130..38dc854 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; } } @@ -85,27 +103,47 @@ 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 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); - renderer.drawRect(25, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back"); + int menuY = 60; + int menuIndex = 0; - renderer.drawRect(130, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35, - "Confirm"); + 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.drawRect(245, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left"); + renderer.drawText(UI_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex); + menuY += 30; + menuIndex++; - 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.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"); renderer.displayBuffer(); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 943a466..0704819 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/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index b9c911e..e0ec682 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -5,6 +5,9 @@ #include #include #include +#include + +#include #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" @@ -336,9 +339,29 @@ void CrossPointWebServerActivity::render() const { } } -void CrossPointWebServerActivity::renderServerRunning() const { - const auto pageHeight = renderer.getScreenHeight(); +void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) { + // Implementation of QR code calculation + // The structure to manage the QR code + QRCode qrcode; + uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; + Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); + qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); + const uint8_t px = 6; // pixels per module + for (uint8_t cy = 0; cy < qrcode.size; cy++) { + for (uint8_t cx = 0; cx < qrcode.size; cx++) { + if (qrcode_getModule(&qrcode, cx, cy)) { + // Serial.print("**"); + renderer.fillRect(x + px * cx, y + px * cy, px, px, true); + } else { + // Serial.print(" "); + } + } + // Serial.print("\n"); + } +} + +void CrossPointWebServerActivity::renderServerRunning() const { // Use consistent line spacing constexpr int LINE_SPACING = 28; // Space between lines @@ -346,7 +369,7 @@ void CrossPointWebServerActivity::renderServerRunning() const { if (isApMode) { // AP mode display - center the content block - const int startY = 55; + int startY = 55; renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); @@ -356,6 +379,13 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network", true, REGULAR); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, + "or scan QR code with your phone to connect to Wifi.", true, REGULAR); + // Show QR code for URL + std::string wifiConfig = std::string("WIFI:T:WPA;S:") + connectedSSID + ";P:" + "" + ";;"; + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); + + startY += 6 * 29 + 3 * LINE_SPACING; // Show primary URL (hostname) std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); @@ -363,8 +393,12 @@ void CrossPointWebServerActivity::renderServerRunning() const { // Show IP address as fallback std::string ipUrl = "or http://" + connectedIP + "/"; renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR); + + // Show QR code for URL + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:", true, + REGULAR); + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl); } else { // STA mode display (original behavior) const int startY = 65; @@ -387,7 +421,12 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); + + // Show QR code for URL + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:", 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 637d82d..af68a20 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 c18e0f5..68c6481 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -150,6 +150,11 @@ void WifiSelectionActivity::processWifiScanResults() { std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; }); + // Show networks with PW first + std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { + return a.hasSavedPassword && !b.hasSavedPassword; + }); + WiFi.scanDelete(); state = WifiSelectionState::NETWORK_LIST; selectedNetworkIndex = 0; @@ -548,11 +553,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 { @@ -580,7 +586,7 @@ void WifiSelectionActivity::renderConnecting() const { if (state == WifiSelectionState::SCANNING) { renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR); } else { - renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD); + renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connecting...", true, BOLD); std::string ssidInfo = "to " + selectedSSID; if (ssidInfo.length() > 25) { diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index eb266d7..a748a7c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,9 +1,9 @@ #include "EpubReaderActivity.h" #include +#include #include #include -#include #include "Battery.h" #include "CrossPointSettings.h" @@ -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; @@ -37,8 +38,8 @@ void EpubReaderActivity::onEnter() { epub->setupCacheDir(); - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); - if (f) { + File f; + if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { currentSpineIndex = data[0] + (data[1] << 8); @@ -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; } @@ -212,7 +220,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, @@ -282,14 +290,16 @@ void EpubReaderActivity::renderScreen() { Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); - uint8_t data[4]; - data[0] = currentSpineIndex & 0xFF; - data[1] = (currentSpineIndex >> 8) & 0xFF; - data[2] = section->currentPage & 0xFF; - data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); - f.close(); + File f; + if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentSpineIndex & 0xFF; + data[1] = (currentSpineIndex >> 8) & 0xFF; + data[2] = section->currentPage & 0xFF; + data[3] = (section->currentPage >> 8) & 0xFF; + f.write(data, 4); + f.close(); + } } void EpubReaderActivity::renderContents(std::unique_ptr page) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 4edbabc..143f56b 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/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1cda06e..3754fa0 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/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index a6c1083..853b06f 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) { @@ -149,7 +162,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/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 2a8f8ae..f642e20 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 d888fb6..519a33a 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 e566d6d..5bb3419 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/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 6314bf6..a0cf195 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -170,7 +170,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); diff --git a/src/main.cpp b/src/main.cpp index cf74479..9b950f1 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,12 +156,27 @@ void onGoToSettings() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); + enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, 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(); - Serial.begin(115200); + + // Only start serial if USB connected + pinMode(UART0_RXD, INPUT); + if (digitalRead(UART0_RXD) == HIGH) { + Serial.begin(115200); + } Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); @@ -172,8 +188,10 @@ 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(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); return; @@ -184,14 +202,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)); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index f14081f..041273f 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,6 +1,7 @@ #include "CrossPointWebServer.h" #include +#include #include #include @@ -339,8 +340,7 @@ void CrossPointWebServer::handleUpload() const { } // Open file for writing - uploadFile = SD.open(filePath.c_str(), FILE_WRITE); - if (!uploadFile) { + if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return;