Compare commits

...

17 Commits

Author SHA1 Message Date
1991AcuraLegend
167f023556
Merge branch 'master' into master 2025-12-26 07:35:26 -06:00
1991AcuraLegend
3f0983cc6f update settings file location 2025-12-26 07:34:49 -06:00
1991AcuraLegend
7e42a79e55 update settings count 2025-12-26 07:32:58 -06:00
1991AcuraLegend
a8c3f76a45 fixing local variables 2025-12-26 06:05:35 -06:00
Dave Allie
aff4dc6628
Fix QRCode import attempt 2
Some checks failed
CI / build (push) Has been cancelled
2025-12-26 11:33:41 +10:00
Dave Allie
98a39374e8
Fix QRCode import 2025-12-26 11:29:27 +10:00
Jonas Diemer
e8c0fb42d4
Network details QR code (#113)
Using QRCode library from pio to generate the QR code.

Done:
- Display QR code for URL in network mode
- minor fixes of layout
- Display QR for URL in AP mode
- Display QR for AP in AP mode

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-26 12:13:40 +11:00
Eunchurn Park
b77af16caa
Add Continue Reading menu and remember last book folder (#129)
## Summary

* **What is the goal of this PR?**

Add a "Continue Reading" feature to improve user experience when
returning to a previously opened book.

* **What changes are included?**

- Add dynamic "Continue: <book name>" menu item in Home screen when a
book was previously opened

- File browser now starts from the folder of the last opened book
instead of always starting from root directory
- Menu dynamically shows 3 or 4 items based on reading history:
  - Without history: `Browse`, `File transfer`, `Settings`
- With history: `Continue: <book>`, `Browse`, `File transfer`,
`Settings`

## Additional Context

* This feature leverages the existing `APP_STATE.openEpubPath` which
already persists the last opened book path
* The Continue Reading menu only appears if the book file still exists
on the SD card
* Book name in the menu is truncated to 25 characters with "..." suffix
if too long
* If the last book's folder was deleted, the file browser gracefully
falls back to root directory
* No new dependencies or significant memory overhead - reuses existing
state management
2025-12-26 11:55:23 +11:00
Brendan O'Leary
e3c1e28b8f
Normalize button hints (#130)
## Summary

This creates a `renderer.drawButtonHints` to make all of the "hints"
over buttons to match the home screen.

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-26 11:54:02 +11:00
Eunchurn Park
dc7544d944
Optimize glyph lookup with binary search (#125)
Replace linear O(n) search with binary search O(log n) for unicode
interval lookup. Korean fonts have many intervals (~30,000+ glyphs), so
this improves text rendering performance during page navigation.

## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)

Replace linear `O(n)` glyph lookup with binary search `O(log n)` to
improve text rendering performance during page navigation.

* **What changes are included?**

- Modified `EpdFont::getGlyph()` to use binary search instead of linear
search for unicode interval lookup
- Added early return for empty interval count

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

- Performance implications: Fonts with many unicode intervals benefit
the most. Korean fonts have ~30,000+ glyphs across multiple intervals,
but any font with significant glyph coverage (CJK, extended Latin,
emoji, etc.) will see improvement.
- Complexity: from `O(n)` to `O(log n)` where n = number of unicode
intervals. For fonts with 10+ intervals, this reduces lookup iterations
significantly.
- Risk: Low - the binary search logic is straightforward and the
intervals are already sorted by unicode codepoint (required for the
original early-exit optimization).
2025-12-26 11:46:17 +11:00
Dave Allie
504c7b307d
Cut release 0.9.0
Some checks failed
CI / build (push) Has been cancelled
2025-12-24 21:49:47 +10:00
Dave Allie
b6bc1f7ed3
New book.bin spine and table of contents cache (#104)
## Summary

* Use single unified cache file for book spine, table of contents, and
core metadata (title, author, cover image)
* Use new temp item store file in OPF parsing to store items to be
rescaned when parsing spine
  * This avoids us holding these items in memory
* Use new toc.bin.tmp and spine.bin.tmp to build out partial toc / spine
data as part of parsing content.opf and the NCX file
  * These files are re-read multiple times to ultimately build book.bin

## Additional Context

* Spec for file format included below as an image
* This should help with:
  * #10 
  * #60 
  * #99
2025-12-24 22:36:13 +11:00
Dave Allie
ea0abaf351
Prevent SD card error causing boot loop (#122)
## Summary

* Prevent SD card error causing boot loop
* We need the screen and fonts to be initialized to show the full screen
error message
* Prior to this change, trying to render the font would crash the
firmware and boot loop it
2025-12-24 22:33:21 +11:00
Dave Allie
2771579007
Add support for blockquote, strong, and em tags (#121)
## Summary

* Add support for blockquote, strong, and em tags
2025-12-24 22:33:17 +11:00
Dave Allie
27035b2b91
Handle 16x16 MCU blocks in JPEG decoding (#120)
## Summary

* Handle 16x16 MCU blocks in JPEG decoding
* We were only correctly handling 8x8 blocks, which means that we did
not correctly support a lot of JPGs leading to an interlacing style on
the images

## Additional Context

* Fixes https://github.com/daveallie/crosspoint-reader/issues/118
2025-12-24 22:21:41 +11:00
Dave Allie
1107590b56
Standardize File handling with FsHelpers (#110)
Some checks failed
CI / build (push) Has been cancelled
## Summary

* Standardize File handling with FsHelpers
* Better central place to manage to logic of if files exist/open for
reading/writing
2025-12-23 14:14:10 +11:00
Dave Allie
66ddb52103
Pin espressif32 platform version 2025-12-23 12:17:12 +11:00
46 changed files with 1299 additions and 438 deletions

View File

@ -12,12 +12,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'

View File

@ -59,14 +59,28 @@ bool EpdFont::hasPrintableChars(const char* string) const {
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals; const EpdUnicodeInterval* intervals = data->intervals;
for (int i = 0; i < data->intervalCount; i++) { const int count = data->intervalCount;
const EpdUnicodeInterval* interval = &intervals[i];
if (cp >= interval->first && cp <= interval->last) { 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)]; return &data->glyph[interval->offset + (cp - interval->first)];
} }
if (cp < interval->first) {
return nullptr;
}
} }
return nullptr; return nullptr;
} }

View File

@ -1,13 +1,11 @@
#include "Epub.h" #include "Epub.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <SD.h> #include <SD.h>
#include <ZipFile.h> #include <ZipFile.h>
#include <map>
#include "Epub/FsHelpers.h"
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNcxParser.h" #include "Epub/parsers/TocNcxParser.h"
@ -44,7 +42,15 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
return true; 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()); Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str());
size_t contentOpfSize; size_t contentOpfSize;
@ -53,7 +59,9 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) {
return false; 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()) { if (!opfParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); 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 // Grab data from opfParser into epub
title = opfParser.title; bookMetadata.title = opfParser.title;
if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) { // TODO: Parse author
coverImageItem = opfParser.items.at(opfParser.coverItemId); bookMetadata.author = "";
} bookMetadata.coverItemHref = opfParser.coverItemHref;
if (!opfParser.tocNcxPath.empty()) { if (!opfParser.tocNcxPath.empty()) {
tocNcxItem = opfParser.tocNcxPath; 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()); Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
return true; return true;
} }
bool Epub::parseTocNcxFile() { bool Epub::parseTocNcxFile() const {
// the ncx file should have been specified in the content.opf file // the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) { if (tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); 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()); Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx"; 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); readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
tempNcxFile.close(); tempNcxFile.close();
tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
const auto ncxSize = tempNcxFile.size(); const auto ncxSize = tempNcxFile.size();
TocNcxParser ncxParser(contentBasePath, ncxSize); TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
if (!ncxParser.setup()) { if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
@ -130,9 +137,7 @@ bool Epub::parseTocNcxFile() {
tempNcxFile.close(); tempNcxFile.close();
SD.remove(tmpNcxPath.c_str()); SD.remove(tmpNcxPath.c_str());
this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size());
return true; return true;
} }
@ -140,48 +145,79 @@ bool Epub::parseTocNcxFile() {
bool Epub::load() { bool Epub::load() {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
std::string contentOpfFilePath; // Initialize spine/TOC cache
if (!findContentOpfFile(&contentOpfFilePath)) { bookMetadataCache.reset(new BookMetadataCache(cachePath));
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
// 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; return false;
} }
Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str()); // OPF Pass
BookMetadataCache::BookMetadata bookMetadata;
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); if (!bookMetadataCache->beginContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis());
if (!parseContentOpf(contentOpfFilePath)) { return false;
}
if (!parseContentOpf(bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
return false; 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()) { if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
return false; return false;
} }
if (!bookMetadataCache->endTocPass()) {
initializeSpineItemSizes(); Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); return false;
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);
} }
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 { 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::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"; } std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
@ -227,24 +270,42 @@ bool Epub::generateCoverBmp() const {
return true; 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()); Serial.printf("[%lu] [EBP] No known cover image\n", millis());
return false; return false;
} }
if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis());
File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
readItemContentsToStream(coverImageItem, coverJpg, 1024);
File coverJpg;
if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close(); coverJpg.close();
coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); return false;
}
File coverBmp;
if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
coverJpg.close(); coverJpg.close();
coverBmp.close(); coverBmp.close();
SD.remove((getCachePath() + "/.cover.jpg").c_str()); SD.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
@ -259,45 +320,9 @@ bool Epub::generateCoverBmp() const {
return false; return false;
} }
std::string normalisePath(const std::string& path) { uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
std::vector<std::string> 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 {
const ZipFile zip("/sd" + filepath); 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); const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte);
if (!content) { 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 { bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
const ZipFile zip("/sd" + filepath); 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); 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) { 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); return zip.getInflatedFileSize(path.c_str(), size);
} }
int Epub::getSpineItemsCount() const { return spine.size(); } int Epub::getSpineItemsCount() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const {
if (spineIndex < 0 || spineIndex >= static_cast<int>(cumulativeSpineItemSize.size())) {
Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex);
return 0; return 0;
} }
return cumulativeSpineItemSize.at(spineIndex); return bookMetadataCache->getSpineCount();
} }
std::string& Epub::getSpineItem(const int spineIndex) { size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
static std::string emptyString;
if (spine.empty()) { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return emptyString; Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
return {};
} }
if (spineIndex < 0 || spineIndex >= static_cast<int>(spine.size())) {
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); 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) { BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
static EpubTocEntry emptyEntry = {}; if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return {};
return emptyEntry;
}
if (tocTndex < 0 || tocTndex >= static_cast<int>(toc.size())) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex);
return toc.at(0);
} }
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 // work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const { 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); Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
return 0; return 0;
} }
// the toc entry should have an href that matches the spine item const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
// so we can find the spine index by looking for the href if (spineIndex < 0) {
for (int i = 0; i < spine.size(); i++) { Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex);
if (spine[i].second == toc[tocIndex].href) { return 0;
return i;
}
} }
Serial.printf("[%lu] [EBP] Section not found\n", millis()); return spineIndex;
// not found - default to the start of the book
return 0;
} }
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
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;
}
size_t Epub::getBookSize() const { size_t Epub::getBookSize() const {
if (spine.empty()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
return 0; return 0;
} }
return getCumulativeSpineItemSize(getSpineItemsCount() - 1); return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
} }
// Calculate progress in book // Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) { uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
size_t bookSize = getBookSize(); const size_t bookSize = getBookSize();
if (bookSize == 0) { if (bookSize == 0) {
return 0; return 0;
} }
size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
size_t sectionProgSize = currentSpineRead * curChapterSize; const size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0); return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
} }

View File

@ -1,38 +1,29 @@
#pragma once #pragma once
#include <Print.h>
#include <memory>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "Epub/EpubTocEntry.h" #include "Epub/BookMetadataCache.h"
class ZipFile; class ZipFile;
class Epub { class Epub {
// the title read from the EPUB meta data
std::string title;
// the cover image
std::string coverImageItem;
// the ncx file // the ncx file
std::string tocNcxItem; std::string tocNcxItem;
// where is the EPUBfile? // where is the EPUBfile?
std::string filepath; std::string filepath;
// the spine of the EPUB file
std::vector<std::pair<std::string, std::string>> spine;
// the file size of the spine items (proxy to book progress)
std::vector<size_t> cumulativeSpineItemSize;
// the toc of the EPUB file
std::vector<EpubTocEntry> toc;
// the base path for items in the EPUB file // the base path for items in the EPUB file
std::string contentBasePath; std::string contentBasePath;
// Uniq cache key based on filepath // Uniq cache key based on filepath
std::string cachePath; std::string cachePath;
// Spine and TOC cache
std::unique_ptr<BookMetadataCache> bookMetadataCache;
bool findContentOpfFile(std::string* contentOpfFile) const; bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(const std::string& contentOpfFilePath); bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile(); bool parseTocNcxFile() const;
void initializeSpineItemSizes();
static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size);
public: public:
@ -54,14 +45,14 @@ class Epub {
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
bool getItemSize(const std::string& itemHref, size_t* size) 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; int getSpineItemsCount() const;
size_t getCumulativeSpineItemSize(const int spineIndex) const;
EpubTocEntry& getTocItem(int tocIndex);
int getTocItemsCount() const; int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const; int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const; int getTocIndexForSpineIndex(int spineIndex) const;
size_t getCumulativeSpineItemSize(int spineIndex) const;
size_t getBookSize() const; size_t getBookSize() const;
uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead); uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const;
}; };

View File

@ -0,0 +1,326 @@
#include "BookMetadataCache.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <ZipFile.h>
#include <vector>
#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<int>(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<int>(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;
}

View File

@ -0,0 +1,87 @@
#pragma once
#include <SD.h>
#include <string>
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; }
};

View File

@ -1,10 +0,0 @@
#pragma once
#include <string>
struct EpubTocEntry {
std::string title;
std::string href;
std::string anchor;
uint8_t level;
};

View File

@ -2,6 +2,26 @@
#include <SD.h> #include <SD.h>
#include <vector>
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) { bool FsHelpers::removeDir(const char* path) {
// 1. Open the directory // 1. Open the directory
File dir = SD.open(path); File dir = SD.open(path);
@ -34,3 +54,39 @@ bool FsHelpers::removeDir(const char* path) {
return SD.rmdir(path); return SD.rmdir(path);
} }
std::string FsHelpers::normalisePath(const std::string& path) {
std::vector<std::string> 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;
}

View File

@ -1,6 +1,12 @@
#pragma once #pragma once
#include <FS.h>
#include <string>
class FsHelpers { class FsHelpers {
public: 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 bool removeDir(const char* path);
static std::string normalisePath(const std::string& path);
}; };

View File

@ -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::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); }
void PageLine::serialize(std::ostream& os) { void PageLine::serialize(File& file) {
serialization::writePod(os, xPos); serialization::writePod(file, xPos);
serialization::writePod(os, yPos); serialization::writePod(file, yPos);
// serialize TextBlock pointed to by PageLine // serialize TextBlock pointed to by PageLine
block->serialize(os); block->serialize(file);
} }
std::unique_ptr<PageLine> PageLine::deserialize(std::istream& is) { std::unique_ptr<PageLine> PageLine::deserialize(File& file) {
int16_t xPos; int16_t xPos;
int16_t yPos; int16_t yPos;
serialization::readPod(is, xPos); serialization::readPod(file, xPos);
serialization::readPod(is, yPos); serialization::readPod(file, yPos);
auto tb = TextBlock::deserialize(is); auto tb = TextBlock::deserialize(file);
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); return std::unique_ptr<PageLine>(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 { void Page::serialize(File& file) const {
serialization::writePod(os, PAGE_FILE_VERSION); serialization::writePod(file, PAGE_FILE_VERSION);
const uint32_t count = elements.size(); const uint32_t count = elements.size();
serialization::writePod(os, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
// Only PageLine exists currently // Only PageLine exists currently
serialization::writePod(os, static_cast<uint8_t>(TAG_PageLine)); serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
el->serialize(os); el->serialize(file);
} }
} }
std::unique_ptr<Page> Page::deserialize(std::istream& is) { std::unique_ptr<Page> Page::deserialize(File& file) {
uint8_t version; uint8_t version;
serialization::readPod(is, version); serialization::readPod(file, version);
if (version != PAGE_FILE_VERSION) { if (version != PAGE_FILE_VERSION) {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version);
return nullptr; return nullptr;
@ -57,14 +57,14 @@ std::unique_ptr<Page> Page::deserialize(std::istream& is) {
auto page = std::unique_ptr<Page>(new Page()); auto page = std::unique_ptr<Page>(new Page());
uint32_t count; uint32_t count;
serialization::readPod(is, count); serialization::readPod(file, count);
for (uint32_t i = 0; i < count; i++) { for (uint32_t i = 0; i < count; i++) {
uint8_t tag; uint8_t tag;
serialization::readPod(is, tag); serialization::readPod(file, tag);
if (tag == TAG_PageLine) { if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(is); auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl)); page->elements.push_back(std::move(pl));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <FS.h>
#include <utility> #include <utility>
#include <vector> #include <vector>
@ -16,7 +18,7 @@ class PageElement {
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId) = 0; 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 // a line from a block element
@ -27,8 +29,8 @@ class PageLine final : public PageElement {
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId) override; void render(GfxRenderer& renderer, int fontId) override;
void serialize(std::ostream& os) override; void serialize(File& file) override;
static std::unique_ptr<PageLine> deserialize(std::istream& is); static std::unique_ptr<PageLine> deserialize(File& file);
}; };
class Page { class Page {
@ -36,6 +38,6 @@ class Page {
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId) const; void render(GfxRenderer& renderer, int fontId) const;
void serialize(std::ostream& os) const; void serialize(File& file) const;
static std::unique_ptr<Page> deserialize(std::istream& is); static std::unique_ptr<Page> deserialize(File& file);
}; };

View File

@ -1,11 +1,9 @@
#include "Section.h" #include "Section.h"
#include <FsHelpers.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
#include "FsHelpers.h"
#include "Page.h" #include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 5;
void Section::onPageComplete(std::unique_ptr<Page> page) { void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; 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); page->serialize(outputFile);
outputFile.close(); outputFile.close();
@ -28,7 +29,10 @@ void Section::onPageComplete(std::unique_ptr<Page> page) {
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) const { 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, SECTION_FILE_VERSION);
serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, fontId);
serialization::writePod(outputFile, lineCompression); 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, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) { const bool extraParagraphSpacing) {
if (!SD.exists(cachePath.c_str())) {
return false;
}
const auto sectionFilePath = cachePath + "/section.bin"; const auto sectionFilePath = cachePath + "/section.bin";
if (!SD.exists(sectionFilePath.c_str())) { File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
return false; return false;
} }
std::ifstream inputFile(("/sd" + sectionFilePath).c_str());
// Match parameters // Match parameters
{ {
uint8_t version; uint8_t version;
@ -117,15 +116,14 @@ bool Section::clearCache() const {
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) { const bool extraParagraphSpacing) {
const auto localPath = epub->getSpineItem(spineIndex); const auto localPath = epub->getSpineItem(spineIndex).href;
// 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 tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); File tmpHtml;
bool success = epub->readItemContentsToStream(localPath, f, 1024); if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
f.close(); return false;
}
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); 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()); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str());
const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, extraParagraphSpacing,
ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
@ -153,13 +149,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
} }
std::unique_ptr<Page> Section::loadPageFromSD() const { std::unique_ptr<Page> Section::loadPageFromSD() const {
const auto filePath = "/sd" + cachePath + "/page_" + std::to_string(currentPage) + ".bin"; const auto filePath = 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()); File inputFile;
if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) {
return nullptr; return nullptr;
} }
std::ifstream inputFile(filePath);
auto page = Page::deserialize(inputFile); auto page = Page::deserialize(inputFile);
inputFile.close(); inputFile.close();
return page; return page;

View File

@ -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 // words
const uint32_t wc = words.size(); const uint32_t wc = words.size();
serialization::writePod(os, wc); serialization::writePod(file, wc);
for (const auto& w : words) serialization::writeString(os, w); for (const auto& w : words) serialization::writeString(file, w);
// wordXpos // wordXpos
const uint32_t xc = wordXpos.size(); const uint32_t xc = wordXpos.size();
serialization::writePod(os, xc); serialization::writePod(file, xc);
for (auto x : wordXpos) serialization::writePod(os, x); for (auto x : wordXpos) serialization::writePod(file, x);
// wordStyles // wordStyles
const uint32_t sc = wordStyles.size(); const uint32_t sc = wordStyles.size();
serialization::writePod(os, sc); serialization::writePod(file, sc);
for (auto s : wordStyles) serialization::writePod(os, s); for (auto s : wordStyles) serialization::writePod(file, s);
// style // style
serialization::writePod(os, style); serialization::writePod(file, style);
} }
std::unique_ptr<TextBlock> TextBlock::deserialize(std::istream& is) { std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
uint32_t wc, xc, sc; uint32_t wc, xc, sc;
std::list<std::string> words; std::list<std::string> words;
std::list<uint16_t> wordXpos; std::list<uint16_t> wordXpos;
@ -45,22 +45,22 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(std::istream& is) {
BLOCK_STYLE style; BLOCK_STYLE style;
// words // words
serialization::readPod(is, wc); serialization::readPod(file, wc);
words.resize(wc); words.resize(wc);
for (auto& w : words) serialization::readString(is, w); for (auto& w : words) serialization::readString(file, w);
// wordXpos // wordXpos
serialization::readPod(is, xc); serialization::readPod(file, xc);
wordXpos.resize(xc); wordXpos.resize(xc);
for (auto& x : wordXpos) serialization::readPod(is, x); for (auto& x : wordXpos) serialization::readPod(file, x);
// wordStyles // wordStyles
serialization::readPod(is, sc); serialization::readPod(file, sc);
wordStyles.resize(sc); wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(is, s); for (auto& s : wordStyles) serialization::readPod(file, s);
// style // style
serialization::readPod(is, style); serialization::readPod(file, style);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style));
} }

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <FS.h>
#include <list> #include <list>
#include <memory> #include <memory>
@ -35,6 +36,6 @@ class TextBlock final : public Block {
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const; void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; } BlockType getType() override { return TEXT_BLOCK; }
void serialize(std::ostream& os) const; void serialize(File& file) const;
static std::unique_ptr<TextBlock> deserialize(std::istream& is); static std::unique_ptr<TextBlock> deserialize(File& file);
}; };

View File

@ -1,5 +1,6 @@
#include "ChapterHtmlSlimParser.h" #include "ChapterHtmlSlimParser.h"
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <expat.h> #include <expat.h>
@ -10,13 +11,13 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); 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]); 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]); 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]); constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]);
const char* IMAGE_TAGS[] = {"img"}; const char* IMAGE_TAGS[] = {"img"};
@ -214,9 +215,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
FILE* file = fopen(filepath, "r"); File file;
if (!file) { if (!FsHelpers::openFileForRead("EHP", filepath, file)) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
XML_ParserFree(parser); XML_ParserFree(parser);
return false; return false;
} }
@ -233,23 +233,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
const size_t len = fread(buf, 1, 1024, file); const size_t len = file.read(static_cast<uint8_t*>(buf), 1024);
if (ferror(file)) { if (len == 0) {
Serial.printf("[%lu] [EHP] File read error\n", millis()); Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
done = feof(file); done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), 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_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
} while (!done); } while (!done);
@ -267,7 +267,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
// Process last page if there is still text // Process last page if there is still text
if (currentTextBlock) { if (currentTextBlock) {

View File

@ -15,7 +15,7 @@ class GfxRenderer;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser { class ChapterHtmlSlimParser {
const char* filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
int depth = 0; int depth = 0;
@ -45,7 +45,7 @@ class ChapterHtmlSlimParser {
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: 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 float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn) const std::function<void(std::unique_ptr<Page>)>& completePageFn)

View File

@ -1,11 +1,16 @@
#include "ContentOpfParser.h" #include "ContentOpfParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.h>
#include <ZipFile.h> #include <ZipFile.h>
#include "../BookMetadataCache.h"
namespace { namespace {
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
} constexpr char itemCacheFile[] = "/.items.bin";
} // namespace
bool ContentOpfParser::setup() { bool ContentOpfParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
@ -28,6 +33,12 @@ ContentOpfParser::~ContentOpfParser() {
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; 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); } 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)) { if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST; 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; return;
} }
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE; 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; 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 (mediaType == MEDIA_TYPE_NCX) {
if (self->tocNcxPath.empty()) { if (self->tocNcxPath.empty()) {
@ -148,14 +175,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return; return;
} }
if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec)
for (int i = 0; atts[i]; i += 2) { // Only run the spine parsing if there's a cache to add it to
if (strcmp(atts[i], "idref") == 0) { if (self->cache) {
self->spineRefs.emplace_back(atts[i + 1]); if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) {
break; 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)) { if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_PACKAGE; self->state = IN_PACKAGE;
self->tempItemStore.close();
return; return;
} }
if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_PACKAGE; self->state = IN_PACKAGE;
self->tempItemStore.close();
return; return;
} }

View File

@ -1,11 +1,11 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <map>
#include "Epub.h" #include "Epub.h"
#include "expat.h" #include "expat.h"
class BookMetadataCache;
class ContentOpfParser final : public Print { class ContentOpfParser final : public Print {
enum ParserState { enum ParserState {
START, START,
@ -16,10 +16,14 @@ class ContentOpfParser final : public Print {
IN_SPINE, IN_SPINE,
}; };
const std::string& cachePath;
const std::string& baseContentPath; const std::string& baseContentPath;
size_t remainingSize; size_t remainingSize;
XML_Parser parser = nullptr; XML_Parser parser = nullptr;
ParserState state = START; 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 startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len); static void characterData(void* userData, const XML_Char* s, int len);
@ -28,12 +32,11 @@ class ContentOpfParser final : public Print {
public: public:
std::string title; std::string title;
std::string tocNcxPath; std::string tocNcxPath;
std::string coverItemId; std::string coverItemHref;
std::map<std::string, std::string> items;
std::vector<std::string> spineRefs;
explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize,
: baseContentPath(baseContentPath), remainingSize(xmlSize) {} BookMetadataCache* cache)
: cachePath(cachePath), baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
~ContentOpfParser() override; ~ContentOpfParser() override;
bool setup(); bool setup();

View File

@ -1,8 +1,9 @@
#include "TocNcxParser.h" #include "TocNcxParser.h"
#include <Esp.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "../BookMetadataCache.h"
bool TocNcxParser::setup() { bool TocNcxParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
@ -167,8 +168,9 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
href = href.substr(0, pos); href = href.substr(0, pos);
} }
// Push to vector if (self->cache) {
self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth);
}
// Clear them so we don't re-add them if there are weird XML structures // Clear them so we don't re-add them if there are weird XML structures
self->currentLabel.clear(); self->currentLabel.clear();

View File

@ -1,11 +1,10 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <expat.h>
#include <string> #include <string>
#include <vector>
#include "Epub/EpubTocEntry.h" class BookMetadataCache;
#include "expat.h"
class TocNcxParser final : public Print { 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 }; 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; size_t remainingSize;
XML_Parser parser = nullptr; XML_Parser parser = nullptr;
ParserState state = START; ParserState state = START;
BookMetadataCache* cache;
std::string currentLabel; std::string currentLabel;
std::string currentSrc; std::string currentSrc;
@ -24,10 +24,8 @@ class TocNcxParser final : public Print {
static void endElement(void* userData, const XML_Char* name); static void endElement(void* userData, const XML_Char* name);
public: public:
std::vector<EpubTocEntry> toc; explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize)
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
~TocNcxParser() override; ~TocNcxParser() override;
bool setup(); bool setup();

112
lib/FsHelpers/FsHelpers.cpp Normal file
View File

@ -0,0 +1,112 @@
#include "FsHelpers.h"
#include <SD.h>
#include <vector>
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<std::string> 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;
}

14
lib/FsHelpers/FsHelpers.h Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include <FS.h>
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);
};

View File

@ -239,6 +239,28 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(REGULAR)->advanceY; 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(); } uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }

View File

@ -57,6 +57,9 @@ class GfxRenderer {
int getSpaceWidth(int fontId) const; int getSpaceWidth(int fontId) const;
int getLineHeight(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 // Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;

View File

@ -182,6 +182,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
} }
// Process MCU block into MCU row buffer // 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 blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX; const int pixelX = mcuX * mcuPixelWidth + blockX;
@ -191,16 +194,27 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
continue; 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 // Get grayscale value
uint8_t gray; uint8_t gray;
if (imageInfo.m_comps == 1) { if (imageInfo.m_comps == 1) {
// Grayscale image // Grayscale image
gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; gray = imageInfo.m_pMCUBufR[mcuIndex];
} else { } else {
// RGB image - convert to grayscale // RGB image - convert to grayscale
const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex];
const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex];
const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex];
// Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B
// Using integer approximation: (30*R + 59*G + 11*B) / 100 // Using integer approximation: (30*R + 59*G + 11*B) / 100
gray = (r * 30 + g * 59 + b * 11) / 100; gray = (r * 30 + g * 59 + b * 11) / 100;

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <FS.h>
#include <iostream> #include <iostream>
namespace serialization { namespace serialization {
@ -7,21 +9,44 @@ static void writePod(std::ostream& os, const T& value) {
os.write(reinterpret_cast<const char*>(&value), sizeof(T)); os.write(reinterpret_cast<const char*>(&value), sizeof(T));
} }
template <typename T>
static void writePod(File& file, const T& value) {
file.write(reinterpret_cast<const uint8_t*>(&value), sizeof(T));
}
template <typename T> template <typename T>
static void readPod(std::istream& is, T& value) { static void readPod(std::istream& is, T& value) {
is.read(reinterpret_cast<char*>(&value), sizeof(T)); is.read(reinterpret_cast<char*>(&value), sizeof(T));
} }
template <typename T>
static void readPod(File& file, T& value) {
file.read(reinterpret_cast<uint8_t*>(&value), sizeof(T));
}
static void writeString(std::ostream& os, const std::string& s) { static void writeString(std::ostream& os, const std::string& s) {
const uint32_t len = s.size(); const uint32_t len = s.size();
writePod(os, len); writePod(os, len);
os.write(s.data(), 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<const uint8_t*>(s.data()), len);
}
static void readString(std::istream& is, std::string& s) { static void readString(std::istream& is, std::string& s) {
uint32_t len; uint32_t len;
readPod(is, len); readPod(is, len);
s.resize(len); s.resize(len);
is.read(&s[0], 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<uint8_t*>(&s[0]), len);
}
} // namespace serialization } // namespace serialization

View File

@ -1,9 +1,9 @@
[platformio] [platformio]
crosspoint_version = 0.8.1 crosspoint_version = 0.9.0
default_envs = default default_envs = default
[base] [base]
platform = espressif32 platform = espressif32 @ 6.12.0
board = esp32-c3-devkitm-1 board = esp32-c3-devkitm-1
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
@ -40,6 +40,7 @@ lib_deps =
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
ArduinoJson @ 7.4.2 ArduinoJson @ 7.4.2
QRCode @ 0.0.1
[env:default] [env:default]
extends = base extends = base

View File

@ -1,26 +1,28 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <cstdint>
#include <fstream>
// Initialize the static instance // Initialize the static instance
CrossPointSettings CrossPointSettings::instance; CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 3; constexpr uint8_t SETTINGS_COUNT = 4;
constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
bool CrossPointSettings::saveToFile() const { bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SD.mkdir("/.crosspoint"); 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_FILE_VERSION);
serialization::writePod(outputFile, SETTINGS_COUNT); serialization::writePod(outputFile, SETTINGS_COUNT);
serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, sleepScreen);
@ -34,13 +36,11 @@ bool CrossPointSettings::saveToFile() const {
} }
bool CrossPointSettings::loadFromFile() { bool CrossPointSettings::loadFromFile() {
if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix File inputFile;
Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis()); if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false; return false;
} }
std::ifstream inputFile(SETTINGS_FILE);
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) { if (version != SETTINGS_FILE_VERSION) {

View File

@ -1,20 +1,22 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
namespace { namespace {
constexpr uint8_t STATE_FILE_VERSION = 1; constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
} // namespace } // namespace
CrossPointState CrossPointState::instance; CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const { 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::writePod(outputFile, STATE_FILE_VERSION);
serialization::writeString(outputFile, openEpubPath); serialization::writeString(outputFile, openEpubPath);
outputFile.close(); outputFile.close();
@ -22,7 +24,10 @@ bool CrossPointState::saveToFile() const {
} }
bool CrossPointState::loadFromFile() { bool CrossPointState::loadFromFile() {
std::ifstream inputFile(STATE_FILE); File inputFile;
if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) {
return false;
}
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);

View File

@ -1,11 +1,10 @@
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
// Initialize the static instance // Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance; WifiCredentialStore WifiCredentialStore::instance;
@ -14,7 +13,7 @@ namespace {
constexpr uint8_t WIFI_FILE_VERSION = 1; constexpr uint8_t WIFI_FILE_VERSION = 1;
// WiFi credentials file path // WiFi credentials file path
constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
// Obfuscation key - "CrossPoint" in ASCII // Obfuscation key - "CrossPoint" in ASCII
// This is NOT cryptographic security, just prevents casual file reading // This is NOT cryptographic security, just prevents casual file reading
@ -33,9 +32,8 @@ bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SD.mkdir("/.crosspoint"); SD.mkdir("/.crosspoint");
std::ofstream file(WIFI_FILE, std::ios::binary); File file;
if (!file) { if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) {
Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis());
return false; return false;
} }
@ -62,14 +60,8 @@ bool WifiCredentialStore::saveToFile() const {
} }
bool WifiCredentialStore::loadFromFile() { bool WifiCredentialStore::loadFromFile() {
if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix File file;
Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) {
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());
return false; return false;
} }

View File

@ -1,6 +1,7 @@
#include "SleepActivity.h" #include "SleepActivity.h"
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <SD.h>
@ -76,8 +77,8 @@ void SleepActivity::renderCustomSleepScreen() const {
// Generate a random number between 1 and numFiles // Generate a random number between 1 and numFiles
const auto randomFileIndex = random(numFiles); const auto randomFileIndex = random(numFiles);
const auto filename = "/sleep/" + files[randomFileIndex]; const auto filename = "/sleep/" + files[randomFileIndex];
auto file = SD.open(filename.c_str()); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100); delay(100);
Bitmap bitmap(file); 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 // 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. // render a custom sleep screen instead of the default.
auto file = SD.open("/sleep.bmp"); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
@ -186,8 +187,8 @@ void SleepActivity::renderCoverSleepScreen() const {
return renderDefaultSleepScreen(); return renderDefaultSleepScreen();
} }
auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);

View File

@ -4,22 +4,24 @@
#include <InputManager.h> #include <InputManager.h>
#include <SD.h> #include <SD.h>
#include "CrossPointState.h"
#include "config.h" #include "config.h"
namespace {
constexpr int menuItemCount = 3;
}
void HomeActivity::taskTrampoline(void* param) { void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param); auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
} }
int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; }
void HomeActivity::onEnter() { void HomeActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); 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; selectorIndex = 0;
// Trigger first update // Trigger first update
@ -52,19 +54,35 @@ void HomeActivity::loop() {
const bool nextPressed = const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
const int menuCount = getMenuItemCount();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (selectorIndex == 0) { if (hasContinueReading) {
onReaderOpen(); // Menu: Continue Reading, Browse, File transfer, Settings
} else if (selectorIndex == 1) { if (selectorIndex == 0) {
onFileTransferOpen(); onContinueReading();
} else if (selectorIndex == 2) { } else if (selectorIndex == 1) {
onSettingsOpen(); 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) { } else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount; selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true; updateRequired = true;
} else if (nextPressed) { } else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount; selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true; updateRequired = true;
} }
} }
@ -85,27 +103,47 @@ void HomeActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection // Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); 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); int menuY = 60;
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back"); int menuIndex = 0;
renderer.drawRect(130, pageHeight - 40, 106, 40); if (hasContinueReading) {
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35, // Extract filename from path for display
"Confirm"); 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, 20, menuY, "Browse", selectorIndex != menuIndex);
renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left"); menuY += 30;
menuIndex++;
renderer.drawRect(350, pageHeight - 40, 106, 40); renderer.drawText(UI_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex);
renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right"); menuY += 30;
menuIndex++;
renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex);
renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right");
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -12,6 +12,8 @@ class HomeActivity final : public Activity {
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool hasContinueReading = false;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen; const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen; const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen; const std::function<void()> onFileTransferOpen;
@ -19,11 +21,14 @@ class HomeActivity final : public Activity {
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
int getMenuItemCount() const;
public: public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen, explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen) const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity("Home", renderer, inputManager), : Activity("Home", renderer, inputManager),
onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen), onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen), onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {} onFileTransferOpen(onFileTransferOpen) {}

View File

@ -5,6 +5,9 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <WiFi.h> #include <WiFi.h>
#include <qrcode.h>
#include <cstddef>
#include "NetworkModeSelectionActivity.h" #include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
@ -336,9 +339,29 @@ void CrossPointWebServerActivity::render() const {
} }
} }
void CrossPointWebServerActivity::renderServerRunning() const { void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) {
const auto pageHeight = renderer.getScreenHeight(); // 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 // Use consistent line spacing
constexpr int LINE_SPACING = 28; // Space between lines constexpr int LINE_SPACING = 28; // Space between lines
@ -346,7 +369,7 @@ void CrossPointWebServerActivity::renderServerRunning() const {
if (isApMode) { if (isApMode) {
// AP mode display - center the content block // AP mode display - center the content block
const int startY = 55; int startY = 55;
renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); 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", renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network",
true, REGULAR); 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) // Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); 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 // Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/"; 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 * 4, ipUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", 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 { } else {
// STA mode display (original behavior) // STA mode display (original behavior)
const int startY = 65; 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 * 3, hostnameUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", 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", "", "", "");
} }

View File

@ -122,7 +122,7 @@ void NetworkModeSelectionActivity::render() const {
} }
// Draw help text at bottom // 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(); renderer.displayBuffer();
} }

View File

@ -150,6 +150,11 @@ void WifiSelectionActivity::processWifiScanResults() {
std::sort(networks.begin(), networks.end(), std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; }); [](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(); WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0; selectedNetworkIndex = 0;
@ -548,11 +553,12 @@ void WifiSelectionActivity::renderNetworkList() const {
// Show network count // Show network count
char countStr[32]; char countStr[32];
snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); 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 // 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 { void WifiSelectionActivity::renderPasswordEntry() const {
@ -580,7 +586,7 @@ void WifiSelectionActivity::renderConnecting() const {
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR);
} else { } 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; std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) { if (ssidInfo.length() > 25) {

View File

@ -1,9 +1,9 @@
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include <Epub/Page.h> #include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <SD.h>
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
@ -14,6 +14,7 @@
namespace { namespace {
constexpr int pagesPerRefresh = 15; constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr float lineCompression = 0.95f; constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8; constexpr int marginTop = 8;
constexpr int marginRight = 10; constexpr int marginRight = 10;
@ -37,8 +38,8 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir(); epub->setupCacheDir();
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); File f;
if (f) { if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
if (f.read(data, 4) == 4) { if (f.read(data, 4) == 4) {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
@ -108,7 +109,14 @@ void EpubReaderActivity::loop() {
xSemaphoreGive(renderingMutex); 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(); onGoBack();
return; return;
} }
@ -212,7 +220,7 @@ void EpubReaderActivity::renderScreen() {
} }
if (!section) { 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); Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, 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); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); File f;
uint8_t data[4]; if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
data[0] = currentSpineIndex & 0xFF; uint8_t data[4];
data[1] = (currentSpineIndex >> 8) & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[2] = section->currentPage & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[2] = section->currentPage & 0xFF;
f.write(data, 4); data[3] = (section->currentPage >> 8) & 0xFF;
f.close(); f.write(data, 4);
f.close();
}
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
@ -338,6 +348,11 @@ void EpubReaderActivity::renderStatusBar() const {
// height variable shared by all elements // height variable shared by all elements
constexpr auto textY = 776; constexpr auto textY = 776;
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
if (showProgress) { if (showProgress) {
// Calculate progress in book // Calculate progress in book
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount; const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
@ -352,10 +367,6 @@ void EpubReaderActivity::renderStatusBar() const {
} }
if (showBattery) { if (showBattery) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body // 1 column on left, 2 columns on right, 5 columns of battery body

View File

@ -17,6 +17,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
@ -26,8 +27,11 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} : ActivityWithSubactivity("EpubReader", renderer, inputManager),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -29,7 +29,7 @@ void EpubReaderChapterSelectionActivity::onEnter() {
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
2048, // Stack size 4096, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle

View File

@ -9,6 +9,7 @@
namespace { namespace {
constexpr int PAGE_ITEMS = 23; constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700; constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
@ -53,7 +54,7 @@ void FileSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
basepath = "/"; // basepath is set via constructor parameter (defaults to "/" if not specified)
loadFiles(); loadFiles();
selectorIndex = 0; selectorIndex = 0;
@ -83,6 +84,16 @@ void FileSelectionActivity::onExit() {
} }
void FileSelectionActivity::loop() { 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 = const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased = const bool nextReleased =
@ -103,15 +114,17 @@ void FileSelectionActivity::loop() {
} else { } else {
onSelect(basepath + files[selectorIndex]); onSelect(basepath + files[selectorIndex]);
} }
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (inputManager.wasReleased(InputManager::BTN_BACK)) {
if (basepath != "/") { // Short press: go up one directory, or go home if at root
basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (inputManager.getHeldTime() < GO_HOME_MS) {
if (basepath.empty()) basepath = "/"; if (basepath != "/") {
loadFiles(); basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
updateRequired = true; if (basepath.empty()) basepath = "/";
} else { loadFiles();
// At root level, go back home updateRequired = true;
onGoHome(); } else {
onGoHome();
}
} }
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {
@ -149,7 +162,7 @@ void FileSelectionActivity::render() const {
renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
// Help text // Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", "");
if (files.empty()) { if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");

View File

@ -27,8 +27,11 @@ class FileSelectionActivity final : public Activity {
public: public:
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect, const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome) const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} : Activity("FileSelection", renderer, inputManager),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -7,6 +7,14 @@
#include "FileSelectionActivity.h" #include "FileSelectionActivity.h"
#include "activities/util/FullScreenMessageActivity.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<Epub> ReaderActivity::loadEpub(const std::string& path) { std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) { if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
@ -23,6 +31,7 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
} }
void ReaderActivity::onSelectEpubFile(const std::string& path) { void ReaderActivity::onSelectEpubFile(const std::string& path) {
currentEpubPath = path; // Track current book path
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); 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(); 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( 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> epub) { void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
const auto epubPath = epub->getPath();
currentEpubPath = epubPath;
exitActivity(); 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() { void ReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
if (initialEpubPath.empty()) { if (initialEpubPath.empty()) {
onGoToFileSelection(); onGoToFileSelection(); // Start from root when entering via Browse
return; return;
} }
currentEpubPath = initialEpubPath;
auto epub = loadEpub(initialEpubPath); auto epub = loadEpub(initialEpubPath);
if (!epub) { if (!epub) {
onGoBack(); onGoBack();

View File

@ -7,11 +7,13 @@ class Epub;
class ReaderActivity final : public ActivityWithSubactivity { class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath; std::string initialEpubPath;
std::string currentEpubPath; // Track current book path for navigation
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path); static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::string extractFolderPath(const std::string& filePath);
void onSelectEpubFile(const std::string& path); void onSelectEpubFile(const std::string& path);
void onGoToFileSelection(); void onGoToFileSelection(const std::string& fromEpubPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub); void onGoToEpubReader(std::unique_ptr<Epub> epub);
public: public:

View File

@ -170,7 +170,7 @@ void SettingsActivity::render() const {
} }
// Draw help text // 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), renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 30, CROSSPOINT_VERSION); pageHeight - 30, CROSSPOINT_VERSION);

View File

@ -142,6 +142,7 @@ void onGoToReader(const std::string& initialEpubPath) {
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome)); enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
} }
void onGoToReaderHome() { onGoToReader(std::string()); } void onGoToReaderHome() { onGoToReader(std::string()); }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
@ -155,12 +156,27 @@ void onGoToSettings() {
void onGoHome() { void onGoHome() {
exitActivity(); 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() { void setup() {
t1 = millis(); 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()); 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); SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
// SD Card Initialization // 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()); Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD));
return; return;
@ -184,14 +202,7 @@ void setup() {
// verify power button press duration after we've read settings. // verify power button press duration after we've read settings.
verifyWakeupLongPress(); verifyWakeupLongPress();
// Initialize display 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());
exitActivity(); exitActivity();
enterNewActivity(new BootActivity(renderer, inputManager)); enterNewActivity(new BootActivity(renderer, inputManager));

View File

@ -1,6 +1,7 @@
#include "CrossPointWebServer.h" #include "CrossPointWebServer.h"
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <FsHelpers.h>
#include <SD.h> #include <SD.h>
#include <WiFi.h> #include <WiFi.h>
@ -339,8 +340,7 @@ void CrossPointWebServer::handleUpload() const {
} }
// Open file for writing // Open file for writing
uploadFile = SD.open(filePath.c_str(), FILE_WRITE); if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) {
if (!uploadFile) {
uploadError = "Failed to create file on SD card"; uploadError = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return; return;