From bf3f270067edace892677aef309177abb981491a Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:34:58 +0100 Subject: [PATCH 01/33] fix: add NULL checks for frameBuffer in GfxRenderer (#79) ## Problem `invertScreen()`, `storeBwBuffer()`, and `restoreBwBuffer()` dereference `frameBuffer` without NULL validation. If the display isn't initialized, these functions will crash. ## Fix Add NULL checks before using `frameBuffer` in all three functions. Follows the existing pattern from `drawPixel()` (line 11) which already validates the pointer. Changed `lib/GfxRenderer/GfxRenderer.cpp`. ## Test - Follows existing validated pattern from `drawPixel()` - No logic changes - only adds early return on NULL - Manual device testing appreciated --- lib/GfxRenderer/GfxRenderer.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 19c959f8..b1a98c7e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -183,6 +183,10 @@ void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScre void GfxRenderer::invertScreen() const { uint8_t* buffer = einkDisplay.getFrameBuffer(); + if (!buffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); + return; + } for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { buffer[i] = ~buffer[i]; } @@ -256,6 +260,10 @@ void GfxRenderer::freeBwBufferChunks() { */ void GfxRenderer::storeBwBuffer() { const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); + return; + } // Allocate and copy each chunk for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { @@ -306,6 +314,12 @@ void GfxRenderer::restoreBwBuffer() { } uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); + freeBwBufferChunks(); + return; + } + for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if chunk is missing if (!bwBufferChunks[i]) { From cc86533e8674f3a9dbd4d32f399d4d455542bfba Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:35:37 +0100 Subject: [PATCH 02/33] fix: add NULL check after malloc in readFileToMemory() (#81) ## Problem `readFileToMemory()` allocates an output buffer via `malloc()` at line 120 but doesn't check if allocation succeeds. On low memory, the NULL pointer is passed to `fread()` causing a crash. ## Fix Add NULL check after `malloc()` for the output buffer. Follows the existing pattern already used for `deflatedData` at line 141. Changed `lib/ZipFile/ZipFile.cpp`. ## Test - Follows existing validated pattern from same function - Defensive addition only - no logic changes --- lib/ZipFile/ZipFile.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index f55bb856..11ce2115 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -118,6 +118,11 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo const auto inflatedDataSize = static_cast(fileStat.m_uncomp_size); const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize; const auto data = static_cast(malloc(dataSize)); + if (data == nullptr) { + Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize); + fclose(file); + return nullptr; + } if (fileStat.m_method == MZ_NO_COMPRESSION) { // no deflation, just read content From 73d1839ddddee63df97c3af1d012bb96b7c43bf3 Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:36:30 +0100 Subject: [PATCH 03/33] fix: add bounds checks to Epub getter functions (#82) ## Problem Three Epub getter functions can throw exceptions: - `getCumulativeSpineItemSize()`: No bounds check before `.at(spineIndex)` - `getSpineItem()`: If spine is empty and index invalid, `.at(0)` throws - `getTocItem()`: If toc is empty and index invalid, `.at(0)` throws ## Fix - Add bounds check to `getCumulativeSpineItemSize()`, return 0 on error - Add empty container checks to `getSpineItem()` and `getTocItem()` - Use static fallback objects for safe reference returns on empty containers Changed `lib/Epub/Epub.cpp`. ## Test - Defensive additions - follows existing bounds check patterns - No logic changes for valid inputs - Manual device testing appreciated --- lib/Epub/Epub.cpp | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index a3edac84..8d3e4ad8 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -298,10 +298,21 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { int Epub::getSpineItemsCount() const { return spine.size(); } -size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return cumulativeSpineItemSize.at(spineIndex); } +size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { + if (spineIndex < 0 || spineIndex >= static_cast(cumulativeSpineItemSize.size())) { + Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); + return 0; + } + return cumulativeSpineItemSize.at(spineIndex); +} std::string& Epub::getSpineItem(const int spineIndex) { - if (spineIndex < 0 || spineIndex >= spine.size()) { + static std::string emptyString; + if (spine.empty()) { + Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); + return emptyString; + } + if (spineIndex < 0 || spineIndex >= static_cast(spine.size())) { Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); return spine.at(0).second; } @@ -310,7 +321,12 @@ std::string& Epub::getSpineItem(const int spineIndex) { } EpubTocEntry& Epub::getTocItem(const int tocTndex) { - if (tocTndex < 0 || tocTndex >= toc.size()) { + static EpubTocEntry emptyEntry("", "", "", 0); + if (toc.empty()) { + Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); + return emptyEntry; + } + if (tocTndex < 0 || tocTndex >= static_cast(toc.size())) { Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); return toc.at(0); } From 9a3bb81337becf05c32d6638769d0438aa8f07a8 Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:36:59 +0100 Subject: [PATCH 04/33] fix: add NULL checks after malloc in drawBmp() (#80) ## Problem `drawBmp()` allocates two row buffers via `malloc()` but doesn't check if allocations succeed. On low memory, this causes a crash when the NULL pointers are dereferenced. ## Fix Add NULL check after both `malloc()` calls. If either fails, log error and return early. Changed `lib/GfxRenderer/GfxRenderer.cpp`. ## Test - Defensive addition only - no logic changes - Manual device testing appreciated --- lib/GfxRenderer/GfxRenderer.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b1a98c7e..a4b9369b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -136,6 +136,13 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. From 299623927ea9d67fe371a56f78142b17b7e70fd9 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 13:43:19 +1100 Subject: [PATCH 05/33] Build out lines when parsing html and holding >750 words in buffer (#73) ## Summary * Build out lines for pages when holding over 750 buffered words * Should fix issues with parsing long blocks of text causing memory crashes --- lib/Epub/Epub/ParsedText.cpp | 139 ++++++++++-------- lib/Epub/Epub/ParsedText.h | 12 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 11 ++ 3 files changed, 96 insertions(+), 66 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 3747246f..eff3fd63 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -19,14 +19,25 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) { // Consumes data to minimize memory usage void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin, - const std::function)>& processLine) { + const std::function)>& processLine, + const bool includeLastLine) { if (words.empty()) { return; } - const size_t totalWordCount = words.size(); const int pageWidth = renderer.getScreenWidth() - horizontalMargin; const int spaceWidth = renderer.getSpaceWidth(fontId); + const auto wordWidths = calculateWordWidths(renderer, fontId); + const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths); + const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; + + for (size_t i = 0; i < lineCount; ++i) { + extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine); + } +} + +std::vector ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) { + const size_t totalWordCount = words.size(); std::vector wordWidths; wordWidths.reserve(totalWordCount); @@ -47,6 +58,13 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo std::advance(wordStylesIt, 1); } + return wordWidths; +} + +std::vector ParsedText::computeLineBreaks(const int pageWidth, const int spaceWidth, + const std::vector& wordWidths) const { + const size_t totalWordCount = words.size(); + // DP table to store the minimum badness (cost) of lines starting at index i std::vector dp(totalWordCount); // 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i' @@ -106,66 +124,59 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo currentWordIndex = nextBreakIndex; } - // Initialize iterators for consumption - auto wordStartIt = words.begin(); - auto wordStyleStartIt = wordStyles.begin(); - size_t wordWidthIndex = 0; - - size_t lastBreakAt = 0; - for (const size_t lineBreak : lineBreakIndices) { - const size_t lineWordCount = lineBreak - lastBreakAt; - - // Calculate end iterators for the range to splice - auto wordEndIt = wordStartIt; - auto wordStyleEndIt = wordStyleStartIt; - std::advance(wordEndIt, lineWordCount); - std::advance(wordStyleEndIt, lineWordCount); - - // Calculate total word width for this line - int lineWordWidthSum = 0; - for (size_t i = 0; i < lineWordCount; ++i) { - lineWordWidthSum += wordWidths[wordWidthIndex + i]; - } - - // Calculate spacing - int spareSpace = pageWidth - lineWordWidthSum; - - int spacing = spaceWidth; - const bool isLastLine = lineBreak == totalWordCount; - - if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { - spacing = spareSpace / (lineWordCount - 1); - } - - // Calculate initial x position - uint16_t xpos = 0; - if (style == TextBlock::RIGHT_ALIGN) { - xpos = spareSpace - (lineWordCount - 1) * spaceWidth; - } else if (style == TextBlock::CENTER_ALIGN) { - xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; - } - - // Pre-calculate X positions for words - std::list lineXPos; - for (size_t i = 0; i < lineWordCount; ++i) { - const uint16_t currentWordWidth = wordWidths[wordWidthIndex + i]; - lineXPos.push_back(xpos); - xpos += currentWordWidth + spacing; - } - - // *** CRITICAL STEP: CONSUME DATA USING SPLICE *** - std::list lineWords; - lineWords.splice(lineWords.begin(), words, wordStartIt, wordEndIt); - std::list lineWordStyles; - lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyleStartIt, wordStyleEndIt); - - processLine( - std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); - - // Update pointers/indices for the next line - wordStartIt = wordEndIt; - wordStyleStartIt = wordStyleEndIt; - wordWidthIndex += lineWordCount; - lastBreakAt = lineBreak; - } + return lineBreakIndices; +} + +void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, + const std::vector& wordWidths, const std::vector& lineBreakIndices, + const std::function)>& processLine) { + const size_t lineBreak = lineBreakIndices[breakIndex]; + const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; + const size_t lineWordCount = lineBreak - lastBreakAt; + + // Calculate total word width for this line + int lineWordWidthSum = 0; + for (size_t i = lastBreakAt; i < lineBreak; i++) { + lineWordWidthSum += wordWidths[i]; + } + + // Calculate spacing + const int spareSpace = pageWidth - lineWordWidthSum; + + int spacing = spaceWidth; + const bool isLastLine = lineBreak == words.size(); + + if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { + spacing = spareSpace / (lineWordCount - 1); + } + + // Calculate initial x position + uint16_t xpos = 0; + if (style == TextBlock::RIGHT_ALIGN) { + xpos = spareSpace - (lineWordCount - 1) * spaceWidth; + } else if (style == TextBlock::CENTER_ALIGN) { + xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; + } + + // Pre-calculate X positions for words + std::list lineXPos; + for (size_t i = lastBreakAt; i < lineBreak; i++) { + const uint16_t currentWordWidth = wordWidths[i]; + lineXPos.push_back(xpos); + xpos += currentWordWidth + spacing; + } + + // Iterators always start at the beginning as we are moving content with splice below + auto wordEndIt = words.begin(); + auto wordStyleEndIt = wordStyles.begin(); + std::advance(wordEndIt, lineWordCount); + std::advance(wordStyleEndIt, lineWordCount); + + // *** CRITICAL STEP: CONSUME DATA USING SPLICE *** + std::list lineWords; + lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt); + std::list lineWordStyles; + lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt); + + processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 0bd25442..7fdb1286 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -2,11 +2,11 @@ #include -#include #include #include #include #include +#include #include "blocks/TextBlock.h" @@ -18,6 +18,12 @@ class ParsedText { TextBlock::BLOCK_STYLE style; bool extraParagraphSpacing; + std::vector computeLineBreaks(int pageWidth, int spaceWidth, const std::vector& wordWidths) const; + void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector& wordWidths, + const std::vector& lineBreakIndices, + const std::function)>& processLine); + std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); + public: explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing) : style(style), extraParagraphSpacing(extraParagraphSpacing) {} @@ -26,7 +32,9 @@ class ParsedText { void addWord(std::string word, EpdFontStyle fontStyle); void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; } TextBlock::BLOCK_STYLE getStyle() const { return style; } + size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin, - const std::function)>& processLine); + const std::function)>& processLine, + bool includeLastLine = true); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index d4edc331..718f4d73 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -143,6 +143,17 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } + + // If we have > 750 words buffered up, perform the layout and consume out all but the last line + // There should be enough here to build out 1-2 full pages and doing this will free up a lot of + // memory. + // Spotted when reading Intermezzo, there are some really long text blocks in there. + if (self->currentTextBlock->size() > 750) { + Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis()); + self->currentTextBlock->layoutAndExtractLines( + self->renderer, self->fontId, self->marginLeft + self->marginRight, + [self](const std::shared_ptr& textBlock) { self->addLineToPage(textBlock); }, false); + } } void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { From 926c7867055c22ce70c9957a38e47da49ee838f1 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sun, 21 Dec 2025 04:38:51 +0100 Subject: [PATCH 06/33] Keep ZipFile open to speed up getting file stats. (#76) Still a bit raw, but gets the time required to determine the size of each chapter (for reading progress) down from ~25ms to 0-1ms. This is done by keeping the zipArchive open (so simple ;)). Probably we don't need to cache the spine sizes anymore then... --------- Co-authored-by: Dave Allie --- lib/Epub/Epub.cpp | 50 ++++++++++++----------------------------- lib/Epub/Epub.h | 1 + lib/ZipFile/ZipFile.cpp | 13 +++++------ lib/ZipFile/ZipFile.h | 6 ++--- 4 files changed, 23 insertions(+), 47 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 8d3e4ad8..0b557503 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -126,7 +126,6 @@ bool Epub::parseTocNcxFile() { // load in the meta data for the epub file bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); - ZipFile zip("/sd" + filepath); std::string contentOpfFilePath; if (!findContentOpfFile(&contentOpfFilePath)) { @@ -155,44 +154,20 @@ bool Epub::load() { } void Epub::initializeSpineItemSizes() { - setupCacheDir(); + Serial.printf("[%lu] [EBP] Calculating book size\n", millis()); - size_t spineItemsCount = getSpineItemsCount(); + const size_t spineItemsCount = getSpineItemsCount(); size_t cumSpineItemSize = 0; - if (SD.exists((getCachePath() + "/spine_size.bin").c_str())) { - File f = SD.open((getCachePath() + "/spine_size.bin").c_str()); - uint8_t data[4]; - for (size_t i = 0; i < spineItemsCount; i++) { - f.read(data, 4); - cumSpineItemSize = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); - // Serial.printf("[%lu] [EBP] Loading item %d size %u to %u %u\n", millis(), - // i, cumSpineItemSize, data[1], data[0]); - } - f.close(); - } else { - File f = SD.open((getCachePath() + "/spine_size.bin").c_str(), FILE_WRITE); - uint8_t data[4]; - // determine size of spine items - for (size_t i = 0; i < spineItemsCount; i++) { - std::string spineItem = getSpineItem(i); - size_t s = 0; - getItemSize(spineItem, &s); - cumSpineItemSize += s; - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); + const ZipFile zip("/sd" + filepath); - // and persist to cache - data[0] = cumSpineItemSize & 0xFF; - data[1] = (cumSpineItemSize >> 8) & 0xFF; - data[2] = (cumSpineItemSize >> 16) & 0xFF; - data[3] = (cumSpineItemSize >> 24) & 0xFF; - // Serial.printf("[%lu] [EBP] Persisting item %d size %u to %u %u\n", millis(), - // i, cumSpineItemSize, data[1], data[0]); - f.write(data, 4); - } - - f.close(); + 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); } @@ -291,8 +266,11 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + return getItemSize(zip, itemHref, size); +} +bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { + const std::string path = normalisePath(itemHref); return zip.getInflatedFileSize(path.c_str(), size); } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index f22b630c..31153035 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -33,6 +33,7 @@ class Epub { bool parseContentOpf(const std::string& contentOpfFilePath); bool parseTocNcxFile(); void initializeSpineItemSizes(); + static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); public: explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index 11ce2115..83b11848 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -27,31 +27,28 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t* return true; } -bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const { - mz_zip_archive zipArchive = {}; - const bool status = mz_zip_reader_init_file(&zipArchive, filePath.c_str(), 0); +ZipFile::ZipFile(std::string filePath) : filePath(std::move(filePath)) { + const bool status = mz_zip_reader_init_file(&zipArchive, this->filePath.c_str(), 0); if (!status) { - Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed! Error: %s\n", millis(), + Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed for %s! Error: %s\n", millis(), this->filePath.c_str(), mz_zip_get_error_string(zipArchive.m_last_error)); - return false; } +} +bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const { // find the file mz_uint32 fileIndex = 0; if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) { Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis(), filename); - mz_zip_reader_end(&zipArchive); return false; } if (!mz_zip_reader_file_stat(&zipArchive, fileIndex, fileStat)) { Serial.printf("[%lu] [ZIP] mz_zip_reader_file_stat() failed! Error: %s\n", millis(), mz_zip_get_error_string(zipArchive.m_last_error)); - mz_zip_reader_end(&zipArchive); return false; } - mz_zip_reader_end(&zipArchive); return true; } diff --git a/lib/ZipFile/ZipFile.h b/lib/ZipFile/ZipFile.h index e452ec5e..58e3ab91 100644 --- a/lib/ZipFile/ZipFile.h +++ b/lib/ZipFile/ZipFile.h @@ -1,19 +1,19 @@ #pragma once #include -#include #include #include "miniz.h" class ZipFile { std::string filePath; + mutable mz_zip_archive zipArchive = {}; bool loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const; long getDataOffset(const mz_zip_archive_file_stat& fileStat) const; public: - explicit ZipFile(std::string filePath) : filePath(std::move(filePath)) {} - ~ZipFile() = default; + explicit ZipFile(std::string filePath); + ~ZipFile() { mz_zip_reader_end(&zipArchive); } bool getInflatedFileSize(const char* filename, size_t* size) const; uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const; bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const; From 9b4dfbd1808ea723f1d60632ca332ff1f4c6d266 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 15:43:17 +1100 Subject: [PATCH 07/33] Allow any file to be uploaded (#84) ## Summary - Allow any file to be uploaded - Removes .epub restriction ## Additional Context - Fixes #74 --- .../network/server/CrossPointWebServer.cpp | 7 -- src/html/FilesPageFooter.html | 96 ++++++++----------- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/activities/network/server/CrossPointWebServer.cpp index 61a88133..90ec915a 100644 --- a/src/activities/network/server/CrossPointWebServer.cpp +++ b/src/activities/network/server/CrossPointWebServer.cpp @@ -513,13 +513,6 @@ void CrossPointWebServer::handleUpload() { Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap()); - // Validate file extension - if (!isEpubFile(uploadFileName)) { - uploadError = "Only .epub files are allowed"; - Serial.printf("[%lu] [WEB] [UPLOAD] REJECTED - not an epub file\n", millis()); - return; - } - // Create file path String filePath = uploadPath; if (!filePath.endsWith("/")) filePath += "/"; diff --git a/src/html/FilesPageFooter.html b/src/html/FilesPageFooter.html index 102430a7..961753a6 100644 --- a/src/html/FilesPageFooter.html +++ b/src/html/FilesPageFooter.html @@ -3,15 +3,15 @@ CrossPoint E-Reader • Open Source

- + - + - + From 0d32d21d756c5b45b5a7b3b3d0a29429763387ff Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 15:43:53 +1100 Subject: [PATCH 08/33] Small code cleanup (#83) ## Summary * Fix cppcheck low violations * Remove teardown method on parsers, use destructor * Code cleanup --- .github/workflows/ci.yml | 2 +- lib/Epub/Epub.cpp | 14 ++---- lib/Epub/Epub/Section.h | 7 +-- lib/Epub/Epub/parsers/ContainerParser.cpp | 3 +- lib/Epub/Epub/parsers/ContainerParser.h | 2 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 5 +-- lib/Epub/Epub/parsers/ContentOpfParser.h | 2 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 4 +- lib/Epub/Epub/parsers/TocNcxParser.h | 2 +- platformio.ini | 2 +- src/WifiCredentialStore.cpp | 34 ++++++++------- src/activities/boot_sleep/SleepActivity.cpp | 2 +- .../network/CrossPointWebServerActivity.cpp | 5 +-- .../network/WifiSelectionActivity.cpp | 43 +++++++++---------- .../network/server/CrossPointWebServer.cpp | 4 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- .../reader/FileSelectionActivity.cpp | 2 +- src/activities/util/KeyboardEntryActivity.cpp | 2 +- src/main.cpp | 19 +++----- 19 files changed, 70 insertions(+), 86 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c090791c..b1d91ec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: sudo apt-get install -y clang-format-21 - name: Run cppcheck - run: pio check --fail-on-defect medium --fail-on-defect high + run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high - name: Run clang-format run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 0b557503..2df5a3f4 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -30,24 +30,22 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const { // Stream read (reusing your existing stream logic) if (!readItemContentsToStream(containerPath, containerParser, 512)) { Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis()); - containerParser.teardown(); return false; } // Extract the result if (containerParser.fullPath.empty()) { Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis()); - containerParser.teardown(); return false; } *contentOpfFile = std::move(containerParser.fullPath); - - containerParser.teardown(); return true; } bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { + Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); + size_t contentOpfSize; if (!getItemSize(contentOpfFilePath, &contentOpfSize)) { Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis()); @@ -63,7 +61,6 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) { Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis()); - opfParser.teardown(); return false; } @@ -84,8 +81,6 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { } Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); - - opfParser.teardown(); return true; } @@ -96,6 +91,8 @@ bool Epub::parseTocNcxFile() { return false; } + Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); + size_t tocSize; if (!getItemSize(tocNcxItem, &tocSize)) { Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis()); @@ -111,15 +108,12 @@ bool Epub::parseTocNcxFile() { if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) { Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis()); - ncxParser.teardown(); return false; } this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); - - ncxParser.teardown(); return true; } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 35a17dfc..d7a2c721 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -21,9 +21,10 @@ class Section { int currentPage = 0; explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer) - : epub(epub), spineIndex(spineIndex), renderer(renderer) { - cachePath = epub->getCachePath() + "/" + std::to_string(spineIndex); - } + : epub(epub), + spineIndex(spineIndex), + renderer(renderer), + cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {} ~Section() = default; bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, int marginLeft, bool extraParagraphSpacing); diff --git a/lib/Epub/Epub/parsers/ContainerParser.cpp b/lib/Epub/Epub/parsers/ContainerParser.cpp index b7ff5d1f..db126f25 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.cpp +++ b/lib/Epub/Epub/parsers/ContainerParser.cpp @@ -14,12 +14,11 @@ bool ContainerParser::setup() { return true; } -bool ContainerParser::teardown() { +ContainerParser::~ContainerParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t ContainerParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/ContainerParser.h b/lib/Epub/Epub/parsers/ContainerParser.h index 07e28abb..39517750 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.h +++ b/lib/Epub/Epub/parsers/ContainerParser.h @@ -23,9 +23,9 @@ class ContainerParser final : public Print { std::string fullPath; explicit ContainerParser(const size_t xmlSize) : remainingSize(xmlSize) {} + ~ContainerParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 472d76cf..5aa73032 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -4,7 +4,7 @@ #include namespace { -constexpr const char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; +constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; } bool ContentOpfParser::setup() { @@ -20,12 +20,11 @@ bool ContentOpfParser::setup() { return true; } -bool ContentOpfParser::teardown() { +ContentOpfParser::~ContentOpfParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index cba45510..a3070fcc 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -34,9 +34,9 @@ class ContentOpfParser final : public Print { explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + ~ContentOpfParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index f02d7c4c..4d541f5c 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,5 +1,6 @@ #include "TocNcxParser.h" +#include #include bool TocNcxParser::setup() { @@ -15,12 +16,11 @@ bool TocNcxParser::setup() { return true; } -bool TocNcxParser::teardown() { +TocNcxParser::~TocNcxParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t TocNcxParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 6217f3f5..5d5df0be 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -28,9 +28,9 @@ class TocNcxParser final : public Print { explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + ~TocNcxParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/platformio.ini b/platformio.ini index 4f71afe4..0f923803 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,8 +9,8 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 check_tool = cppcheck +check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --inline-suppr check_skip_packages = yes -check_severity = medium, high board_upload.flash_size = 16MB board_upload.maximum_size = 16777216 diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index ec9d955a..7df9e2fe 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -111,12 +111,12 @@ bool WifiCredentialStore::loadFromFile() { bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) { // Check if this SSID already exists and update it - for (auto& cred : credentials) { - if (cred.ssid == ssid) { - cred.password = password; - Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); - return saveToFile(); - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + if (cred != credentials.end()) { + cred->password = password; + Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); } // Check if we've reached the limit @@ -132,22 +132,24 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri } bool WifiCredentialStore::removeCredential(const std::string& ssid) { - for (auto it = credentials.begin(); it != credentials.end(); ++it) { - if (it->ssid == ssid) { - credentials.erase(it); - Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); - return saveToFile(); - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + if (cred != credentials.end()) { + credentials.erase(cred); + Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); } return false; // Not found } const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const { - for (const auto& cred : credentials) { - if (cred.ssid == ssid) { - return &cred; - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + + if (cred != credentials.end()) { + return &*cred; } + return nullptr; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 3b369905..900a0dbb 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,11 +1,11 @@ #include "SleepActivity.h" #include +#include #include #include "CrossPointSettings.h" -#include "SD.h" #include "config.h" #include "images/CrossLarge.h" diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 82f63294..bb0f39a2 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -217,8 +217,7 @@ void CrossPointWebServerActivity::render() const { } void CrossPointWebServerActivity::renderServerRunning() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 5) / 2; @@ -226,7 +225,7 @@ void CrossPointWebServerActivity::renderServerRunning() const { std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index a48891ea..d6c3b1ec 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -138,6 +138,7 @@ void WifiSelectionActivity::processWifiScanResults() { // Convert map to vector networks.clear(); for (const auto& pair : uniqueNetworks) { + // cppcheck-suppress useStlAlgorithm networks.push_back(pair.second); } @@ -334,11 +335,10 @@ void WifiSelectionActivity::loop() { // User chose "Yes" - forget the network WIFI_STORE.removeCredential(selectedSSID); // Update the network list to reflect the change - for (auto& network : networks) { - if (network.ssid == selectedSSID) { - network.hasSavedPassword = false; - break; - } + const auto network = find_if(networks.begin(), networks.end(), + [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; }); + if (network != networks.end()) { + network->hasSavedPassword = false; } } // Go back to network list @@ -468,8 +468,8 @@ void WifiSelectionActivity::render() const { } void WifiSelectionActivity::renderNetworkList() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); // Draw header renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD); @@ -506,7 +506,7 @@ void WifiSelectionActivity::renderNetworkList() const { // Draw network name (truncate if too long) std::string displayName = network.ssid; if (displayName.length() > 16) { - displayName = displayName.substr(0, 13) + "..."; + displayName.replace(13, displayName.length() - 13, "..."); } renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str()); @@ -544,15 +544,13 @@ void WifiSelectionActivity::renderNetworkList() const { } void WifiSelectionActivity::renderPasswordEntry() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); - // Draw header renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD); // Draw network name with good spacing from header std::string networkInfo = "Network: " + selectedSSID; if (networkInfo.length() > 30) { - networkInfo = networkInfo.substr(0, 27) + "..."; + networkInfo.replace(27, networkInfo.length() - 27, "..."); } renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); @@ -563,7 +561,7 @@ void WifiSelectionActivity::renderPasswordEntry() const { } void WifiSelectionActivity::renderConnecting() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height) / 2; @@ -574,15 +572,14 @@ void WifiSelectionActivity::renderConnecting() const { std::string ssidInfo = "to " + selectedSSID; if (ssidInfo.length() > 25) { - ssidInfo = ssidInfo.substr(0, 22) + "..."; + ssidInfo.replace(22, ssidInfo.length() - 22, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); } } void WifiSelectionActivity::renderConnected() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 4) / 2; @@ -590,7 +587,7 @@ void WifiSelectionActivity::renderConnected() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); @@ -601,8 +598,8 @@ void WifiSelectionActivity::renderConnected() const { } void WifiSelectionActivity::renderSavePrompt() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 3) / 2; @@ -610,7 +607,7 @@ void WifiSelectionActivity::renderSavePrompt() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); @@ -641,7 +638,7 @@ void WifiSelectionActivity::renderSavePrompt() const { } void WifiSelectionActivity::renderConnectionFailed() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 2) / 2; @@ -651,8 +648,8 @@ void WifiSelectionActivity::renderConnectionFailed() const { } void WifiSelectionActivity::renderForgetPrompt() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 3) / 2; @@ -660,7 +657,7 @@ void WifiSelectionActivity::renderForgetPrompt() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/activities/network/server/CrossPointWebServer.cpp index 90ec915a..62916277 100644 --- a/src/activities/network/server/CrossPointWebServer.cpp +++ b/src/activities/network/server/CrossPointWebServer.cpp @@ -383,9 +383,7 @@ void CrossPointWebServer::handleFileList() { // Folders come first if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory; // Then sort by epub status (epubs first among files) - if (!a.isDirectory && !b.isDirectory) { - if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; - } + if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; // Then alphabetically return a.name < b.name; }); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fc4504d5..8827e998 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -383,7 +383,7 @@ void EpubReaderActivity::renderStatusBar() const { title = tocItem.title; titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); while (titleWidth > availableTextWidth && title.length() > 11) { - title = title.substr(0, title.length() - 8) + "..."; + title.replace(title.length() - 8, 8, "..."); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } } diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 9e665cb2..4389461f 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -93,7 +93,7 @@ void FileSelectionActivity::loop() { } } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (basepath != "/") { - basepath = basepath.substr(0, basepath.rfind('/')); + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); updateRequired = true; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 40d6fedf..68fc0792 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -20,7 +20,7 @@ KeyboardEntryActivity::KeyboardEntryActivity(GfxRenderer& renderer, InputManager void KeyboardEntryActivity::setText(const std::string& newText) { text = newText; if (maxLength > 0 && text.length() > maxLength) { - text = text.substr(0, maxLength); + text.resize(maxLength); } } diff --git a/src/main.cpp b/src/main.cpp index 86169a54..89d2af48 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -203,20 +202,18 @@ void setup() { } void loop() { - static unsigned long lastLoopTime = 0; static unsigned long maxLoopDuration = 0; - - unsigned long loopStartTime = millis(); - + const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; + + inputManager.update(); + if (Serial && millis() - lastMemPrint >= 10000) { Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), ESP.getHeapSize(), ESP.getMinFreeHeap()); lastMemPrint = millis(); } - inputManager.update(); - // Check for any user activity (button press or release) static unsigned long lastActivityTime = millis(); if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) { @@ -237,13 +234,13 @@ void loop() { return; } - unsigned long activityStartTime = millis(); + const unsigned long activityStartTime = millis(); if (currentActivity) { currentActivity->loop(); } - unsigned long activityDuration = millis() - activityStartTime; + const unsigned long activityDuration = millis() - activityStartTime; - unsigned long loopDuration = millis() - loopStartTime; + const unsigned long loopDuration = millis() - loopStartTime; if (loopDuration > maxLoopDuration) { maxLoopDuration = loopDuration; if (maxLoopDuration > 50) { @@ -252,8 +249,6 @@ void loop() { } } - lastLoopTime = loopStartTime; - // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power From f264efdb12a47c6f07c475f8ff734c71658f6833 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:08:34 +1100 Subject: [PATCH 09/33] Extract EPUB TOC into temp file before parsing (#85) ## Summary * Extract EPUB TOC into temp file before parsing * Streaming ZIP -> XML parser uses up a lot of memory as we're allocating inflation buffers while also holding a few copies of the buffer in different forms * Instead, but streaming the inflated file down to the SD card (like we do for HTML parsing, we can lower memory usage) ## Additional Context * This should help with https://github.com/daveallie/crosspoint-reader/issues/60 and https://github.com/daveallie/crosspoint-reader/issues/10. It won't remove those class of issues completely, but will allow for many more books to be opened. --- lib/Epub/Epub.cpp | 36 +++++++++++++++++++------- lib/Epub/Epub/EpubTocEntry.h | 7 ++--- lib/Epub/Epub/parsers/TocNcxParser.cpp | 2 +- lib/Epub/Epub/parsers/TocNcxParser.h | 2 +- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 2df5a3f4..cc3bc909 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -93,24 +93,42 @@ bool Epub::parseTocNcxFile() { Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); - size_t tocSize; - if (!getItemSize(tocNcxItem, &tocSize)) { - Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis()); - return false; - } + const auto tmpNcxPath = getCachePath() + "/toc.ncx"; + File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); + readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); + tempNcxFile.close(); + tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); + const auto ncxSize = tempNcxFile.size(); - TocNcxParser ncxParser(contentBasePath, tocSize); + TocNcxParser ncxParser(contentBasePath, ncxSize); if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); return false; } - if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) { - Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis()); + const auto ncxBuffer = static_cast(malloc(1024)); + if (!ncxBuffer) { + Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis()); return false; } + while (tempNcxFile.available()) { + const auto readSize = tempNcxFile.read(ncxBuffer, 1024); + const auto processedSize = ncxParser.write(ncxBuffer, readSize); + + if (processedSize != readSize) { + Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis()); + free(ncxBuffer); + tempNcxFile.close(); + return false; + } + } + + free(ncxBuffer); + tempNcxFile.close(); + SD.remove(tmpNcxPath.c_str()); + this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); @@ -293,7 +311,7 @@ std::string& Epub::getSpineItem(const int spineIndex) { } EpubTocEntry& Epub::getTocItem(const int tocTndex) { - static EpubTocEntry emptyEntry("", "", "", 0); + static EpubTocEntry emptyEntry = {}; if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return emptyEntry; diff --git a/lib/Epub/Epub/EpubTocEntry.h b/lib/Epub/Epub/EpubTocEntry.h index 715e4a44..94f0c90f 100644 --- a/lib/Epub/Epub/EpubTocEntry.h +++ b/lib/Epub/Epub/EpubTocEntry.h @@ -2,12 +2,9 @@ #include -class EpubTocEntry { - public: +struct EpubTocEntry { std::string title; std::string href; std::string anchor; - int level; - EpubTocEntry(std::string title, std::string href, std::string anchor, const int level) - : title(std::move(title)), href(std::move(href)), anchor(std::move(anchor)), level(level) {} + uint8_t level; }; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index 4d541f5c..0a613f33 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -155,7 +155,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { } // Push to vector - self->toc.emplace_back(self->currentLabel, href, anchor, self->currentDepth); + self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); // Clear them so we don't re-add them if there are weird XML structures self->currentLabel.clear(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 5d5df0be..2f3601a1 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -17,7 +17,7 @@ class TocNcxParser final : public Print { std::string currentLabel; std::string currentSrc; - size_t currentDepth = 0; + uint8_t currentDepth = 0; static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void characterData(void* userData, const XML_Char* s, int len); From b73ae7fe747c48dc46ae24768c2408b1202e29bf Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:12:53 +1100 Subject: [PATCH 10/33] Paginate book list and avoid out of bounds rendering (#86) ## Summary * Paginate book list * Avoid out of bounds rendering of long book titles, truncate with ellipsis instead ## Additional Context * Should partially help with https://github.com/daveallie/crosspoint-reader/issues/75 as it was previously rendering a lot of content off screen, will need to test with a large directory --- .../EpubReaderChapterSelectionActivity.cpp | 2 + .../reader/FileSelectionActivity.cpp | 51 +++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index f4dfc373..9af556ba 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -5,8 +5,10 @@ #include "config.h" +namespace { constexpr int PAGE_ITEMS = 24; constexpr int SKIP_PAGE_MS = 700; +} // namespace void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { auto* self = static_cast(param); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 4389461f..d8aef3c7 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -5,6 +5,11 @@ #include "config.h" +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +} // namespace + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { if (str1.back() == '/' && str2.back() != '/') return true; @@ -73,10 +78,12 @@ void FileSelectionActivity::onExit() { } void FileSelectionActivity::loop() { - const bool prevPressed = - inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); - const bool nextPressed = - inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + const bool prevReleased = + inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); + const bool nextReleased = + inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); + + const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (files.empty()) { @@ -101,11 +108,19 @@ void FileSelectionActivity::loop() { // At root level, go back home onGoHome(); } - } else if (prevPressed) { - selectorIndex = (selectorIndex + files.size() - 1) % files.size(); + } else if (prevReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size(); + } else { + selectorIndex = (selectorIndex + files.size() - 1) % files.size(); + } updateRequired = true; - } else if (nextPressed) { - selectorIndex = (selectorIndex + 1) % files.size(); + } else if (nextReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size(); + } else { + selectorIndex = (selectorIndex + 1) % files.size(); + } updateRequired = true; } } @@ -126,21 +141,27 @@ void FileSelectionActivity::render() const { renderer.clearScreen(); const auto pageWidth = GfxRenderer::getScreenWidth(); - renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); + renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); if (files.empty()) { renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); - } else { - // Draw selection - renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); + renderer.displayBuffer(); + return; + } - for (size_t i = 0; i < files.size(); i++) { - const auto file = files[i]; - renderer.drawText(UI_FONT_ID, 20, 60 + i * 30, file.c_str(), i != selectorIndex); + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); + for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { + auto item = files[i]; + int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str()); + while (itemWidth > renderer.getScreenWidth() - 40 && item.length() > 8) { + item.replace(item.length() - 5, 5, "..."); + itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str()); } + renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); } renderer.displayBuffer(); From 2a27c6d06890cc447aa772f3edc993c2d0e1982b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:15:17 +1100 Subject: [PATCH 11/33] Add JPG image support (#23) ## Summary - Add basic JPG image support - Map JPG back to 2-bit BMP output - Can be used to later render the BMP file from disk or directly pass to output if wanted - Give the 3 passes over the data needed to render grayscale content, putting it on disk is preferred to outputting it multiple times ## Additional Context - WIP, looking forward to BMP support from https://github.com/daveallie/crosspoint-reader/pull/16 - Addresses some of #11 --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 244 ++ lib/JpegToBmpConverter/JpegToBmpConverter.h | 15 + lib/picojpeg/picojpeg.c | 2087 +++++++++++++++++ lib/picojpeg/picojpeg.h | 124 + 4 files changed, 2470 insertions(+) create mode 100644 lib/JpegToBmpConverter/JpegToBmpConverter.cpp create mode 100644 lib/JpegToBmpConverter/JpegToBmpConverter.h create mode 100644 lib/picojpeg/picojpeg.c create mode 100644 lib/picojpeg/picojpeg.h diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp new file mode 100644 index 00000000..4b48d70a --- /dev/null +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -0,0 +1,244 @@ +#include "JpegToBmpConverter.h" + +#include + +#include +#include + +// Context structure for picojpeg callback +struct JpegReadContext { + File& file; + uint8_t buffer[512]; + size_t bufferPos; + size_t bufferFilled; +}; + +// Helper function: Convert 8-bit grayscale to 2-bit (0-3) +uint8_t JpegToBmpConverter::grayscaleTo2Bit(const uint8_t grayscale) { + // Simple threshold mapping: + // 0-63 -> 0 (black) + // 64-127 -> 1 (dark gray) + // 128-191 -> 2 (light gray) + // 192-255 -> 3 (white) + return grayscale >> 6; +} + +inline void write16(Print& out, const uint16_t value) { + // out.write(reinterpret_cast(&value), 2); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); +} + +inline void write32(Print& out, const uint32_t value) { + // out.write(reinterpret_cast(&value), 4); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +inline void write32Signed(Print& out, const int32_t value) { + // out.write(reinterpret_cast(&value), 4); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +// Helper function: Write BMP header with 2-bit color depth +void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 70); // Offset to pixel data + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 2); // Bits per pixel (2 bits) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 4); // colorsUsed + write32(bmpOut, 4); // colorsImportant + + // Color Palette (4 colors x 4 bytes = 16 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + uint8_t palette[16] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) + 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) + 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + +// Callback function for picojpeg to read JPEG data +unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data) { + auto* context = static_cast(pCallback_data); + + if (!context || !context->file) { + return PJPG_STREAM_READ_ERROR; + } + + // Check if we need to refill our context buffer + if (context->bufferPos >= context->bufferFilled) { + context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer)); + context->bufferPos = 0; + + if (context->bufferFilled == 0) { + // EOF or error + *pBytes_actually_read = 0; + return 0; // Success (EOF is normal) + } + } + + // Copy available bytes to picojpeg's buffer + const size_t available = context->bufferFilled - context->bufferPos; + const size_t toRead = available < buf_size ? available : buf_size; + + memcpy(pBuf, context->buffer + context->bufferPos, toRead); + context->bufferPos += toRead; + *pBytes_actually_read = static_cast(toRead); + + return 0; // Success +} + +// Core function: Convert JPEG file to 2-bit BMP +bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { + Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); + + // Setup context for picojpeg callback + JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; + + // Initialize picojpeg decoder + pjpeg_image_info_t imageInfo; + const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); + if (status != 0) { + Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status); + return false; + } + + Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, + imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); + + // Write BMP header + writeBmpHeader(bmpOut, imageInfo.m_width, imageInfo.m_height); + + // Calculate row parameters + const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4; + + // Allocate row buffer for packed 2-bit pixels + auto* rowBuffer = static_cast(malloc(bytesPerRow)); + if (!rowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); + return false; + } + + // Allocate a buffer for one MCU row worth of grayscale pixels + // This is the minimal memory needed for streaming conversion + const int mcuPixelHeight = imageInfo.m_MCUHeight; + const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; + auto* mcuRowBuffer = static_cast(malloc(mcuRowPixels)); + if (!mcuRowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer\n", millis()); + free(rowBuffer); + return false; + } + + // Process MCUs row-by-row and write to BMP as we go (top-down) + const int mcuPixelWidth = imageInfo.m_MCUWidth; + + for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) { + // Clear the MCU row buffer + memset(mcuRowBuffer, 0, mcuRowPixels); + + // Decode one row of MCUs + for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) { + const unsigned char mcuStatus = pjpeg_decode_mcu(); + if (mcuStatus != 0) { + if (mcuStatus == PJPG_NO_MORE_BLOCKS) { + Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY); + } else { + Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY, + mcuStatus); + } + free(mcuRowBuffer); + free(rowBuffer); + return false; + } + + // Process MCU block into MCU row buffer + for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { + for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { + const int pixelX = mcuX * mcuPixelWidth + blockX; + + // Skip pixels outside image width (can happen with MCU alignment) + if (pixelX >= imageInfo.m_width) { + continue; + } + + // Get grayscale value + uint8_t gray; + if (imageInfo.m_comps == 1) { + // Grayscale image + gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + } else { + // RGB image - convert to grayscale + const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; + const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; + // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B + // Using integer approximation: (30*R + 59*G + 11*B) / 100 + gray = (r * 30 + g * 59 + b * 11) / 100; + } + + // Store grayscale value in MCU row buffer + mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; + } + } + } + + // Write all pixel rows from this MCU row to BMP file + const int startRow = mcuY * mcuPixelHeight; + const int endRow = (mcuY + 1) * mcuPixelHeight; + + for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { + memset(rowBuffer, 0, bytesPerRow); + + // Pack 4 pixels per byte (2 bits each) + for (int x = 0; x < imageInfo.m_width; x++) { + const int bufferY = y - startRow; + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t twoBit = grayscaleTo2Bit(gray); + + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0 + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + + // Write row with padding + bmpOut.write(rowBuffer, bytesPerRow); + } + } + + // Clean up + free(mcuRowBuffer); + free(rowBuffer); + + Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); + return true; +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h new file mode 100644 index 00000000..fc881e25 --- /dev/null +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class ZipFile; + +class JpegToBmpConverter { + static void writeBmpHeader(Print& bmpOut, int width, int height); + static uint8_t grayscaleTo2Bit(uint8_t grayscale); + static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data); + + public: + static bool jpegFileToBmpStream(File& jpegFile, Print& bmpOut); +}; diff --git a/lib/picojpeg/picojpeg.c b/lib/picojpeg/picojpeg.c new file mode 100644 index 00000000..f612b73c --- /dev/null +++ b/lib/picojpeg/picojpeg.c @@ -0,0 +1,2087 @@ +//------------------------------------------------------------------------------ +// picojpeg.c v1.1 - Public domain, Rich Geldreich +// Nov. 27, 2010 - Initial release +// Feb. 9, 2013 - Added H1V2/H2V1 support, cleaned up macros, signed shift fixes +// Also integrated and tested changes from Chris Phoenix . +//------------------------------------------------------------------------------ +#include "picojpeg.h" +//------------------------------------------------------------------------------ +// Set to 1 if right shifts on signed ints are always unsigned (logical) shifts +// When 1, arithmetic right shifts will be emulated by using a logical shift +// with special case code to ensure the sign bit is replicated. +#define PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED 0 + +// Define PJPG_INLINE to "inline" if your C compiler supports explicit inlining +#define PJPG_INLINE +//------------------------------------------------------------------------------ +typedef unsigned char uint8; +typedef unsigned short uint16; +typedef signed char int8; +typedef signed short int16; +//------------------------------------------------------------------------------ +#if PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED +static int16 replicateSignBit16(int8 n) { + switch (n) { + case 0: + return 0x0000; + case 1: + return 0x8000; + case 2: + return 0xC000; + case 3: + return 0xE000; + case 4: + return 0xF000; + case 5: + return 0xF800; + case 6: + return 0xFC00; + case 7: + return 0xFE00; + case 8: + return 0xFF00; + case 9: + return 0xFF80; + case 10: + return 0xFFC0; + case 11: + return 0xFFE0; + case 12: + return 0xFFF0; + case 13: + return 0xFFF8; + case 14: + return 0xFFFC; + case 15: + return 0xFFFE; + default: + return 0xFFFF; + } +} +static PJPG_INLINE int16 arithmeticRightShiftN16(int16 x, int8 n) { + int16 r = (uint16)x >> (uint8)n; + if (x < 0) r |= replicateSignBit16(n); + return r; +} +static PJPG_INLINE long arithmeticRightShift8L(long x) { + long r = (unsigned long)x >> 8U; + if (x < 0) r |= ~(~(unsigned long)0U >> 8U); + return r; +} +#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) arithmeticRightShiftN16(x, n) +#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) arithmeticRightShift8L(x) +#else +#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) ((x) >> (n)) +#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) ((x) >> 8) +#endif +//------------------------------------------------------------------------------ +// Change as needed - the PJPG_MAX_WIDTH/PJPG_MAX_HEIGHT checks are only present +// to quickly detect bogus files. +#define PJPG_MAX_WIDTH 16384 +#define PJPG_MAX_HEIGHT 16384 +#define PJPG_MAXCOMPSINSCAN 3 +//------------------------------------------------------------------------------ +typedef enum { + M_SOF0 = 0xC0, + M_SOF1 = 0xC1, + M_SOF2 = 0xC2, + M_SOF3 = 0xC3, + + M_SOF5 = 0xC5, + M_SOF6 = 0xC6, + M_SOF7 = 0xC7, + + M_JPG = 0xC8, + M_SOF9 = 0xC9, + M_SOF10 = 0xCA, + M_SOF11 = 0xCB, + + M_SOF13 = 0xCD, + M_SOF14 = 0xCE, + M_SOF15 = 0xCF, + + M_DHT = 0xC4, + + M_DAC = 0xCC, + + M_RST0 = 0xD0, + M_RST1 = 0xD1, + M_RST2 = 0xD2, + M_RST3 = 0xD3, + M_RST4 = 0xD4, + M_RST5 = 0xD5, + M_RST6 = 0xD6, + M_RST7 = 0xD7, + + M_SOI = 0xD8, + M_EOI = 0xD9, + M_SOS = 0xDA, + M_DQT = 0xDB, + M_DNL = 0xDC, + M_DRI = 0xDD, + M_DHP = 0xDE, + M_EXP = 0xDF, + + M_APP0 = 0xE0, + M_APP15 = 0xEF, + + M_JPG0 = 0xF0, + M_JPG13 = 0xFD, + M_COM = 0xFE, + + M_TEM = 0x01, + + M_ERROR = 0x100, + + RST0 = 0xD0 +} JPEG_MARKER; +//------------------------------------------------------------------------------ +static const int8 ZAG[] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, + 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, + 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +}; +//------------------------------------------------------------------------------ +// 128 bytes +static int16 gCoeffBuf[8 * 8]; + +// 8*8*4 bytes * 3 = 768 +static uint8 gMCUBufR[256]; +static uint8 gMCUBufG[256]; +static uint8 gMCUBufB[256]; + +// 256 bytes +static int16 gQuant0[8 * 8]; +static int16 gQuant1[8 * 8]; + +// 6 bytes +static int16 gLastDC[3]; + +typedef struct HuffTableT { + uint16 mMinCode[16]; + uint16 mMaxCode[16]; + uint8 mValPtr[16]; +} HuffTable; + +// DC - 192 +static HuffTable gHuffTab0; + +static uint8 gHuffVal0[16]; + +static HuffTable gHuffTab1; +static uint8 gHuffVal1[16]; + +// AC - 672 +static HuffTable gHuffTab2; +static uint8 gHuffVal2[256]; + +static HuffTable gHuffTab3; +static uint8 gHuffVal3[256]; + +static uint8 gValidHuffTables; +static uint8 gValidQuantTables; + +static uint8 gTemFlag; +#define PJPG_MAX_IN_BUF_SIZE 256 +static uint8 gInBuf[PJPG_MAX_IN_BUF_SIZE]; +static uint8 gInBufOfs; +static uint8 gInBufLeft; + +static uint16 gBitBuf; +static uint8 gBitsLeft; +//------------------------------------------------------------------------------ +static uint16 gImageXSize; +static uint16 gImageYSize; +static uint8 gCompsInFrame; +static uint8 gCompIdent[3]; +static uint8 gCompHSamp[3]; +static uint8 gCompVSamp[3]; +static uint8 gCompQuant[3]; + +static uint16 gRestartInterval; +static uint16 gNextRestartNum; +static uint16 gRestartsLeft; + +static uint8 gCompsInScan; +static uint8 gCompList[3]; +static uint8 gCompDCTab[3]; // 0,1 +static uint8 gCompACTab[3]; // 0,1 + +static pjpeg_scan_type_t gScanType; + +static uint8 gMaxBlocksPerMCU; +static uint8 gMaxMCUXSize; +static uint8 gMaxMCUYSize; +static uint16 gMaxMCUSPerRow; +static uint16 gMaxMCUSPerCol; + +static uint16 gNumMCUSRemainingX, gNumMCUSRemainingY; + +static uint8 gMCUOrg[6]; + +static pjpeg_need_bytes_callback_t g_pNeedBytesCallback; +static void* g_pCallback_data; +static uint8 gCallbackStatus; +static uint8 gReduce; +//------------------------------------------------------------------------------ +static void fillInBuf(void) { + unsigned char status; + + // Reserve a few bytes at the beginning of the buffer for putting back ("stuffing") chars. + gInBufOfs = 4; + gInBufLeft = 0; + + status = (*g_pNeedBytesCallback)(gInBuf + gInBufOfs, PJPG_MAX_IN_BUF_SIZE - gInBufOfs, &gInBufLeft, g_pCallback_data); + if (status) { + // The user provided need bytes callback has indicated an error, so record the error and continue trying to decode. + // The highest level pjpeg entrypoints will catch the error and return the non-zero status. + gCallbackStatus = status; + } +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getChar(void) { + if (!gInBufLeft) { + fillInBuf(); + if (!gInBufLeft) { + gTemFlag = ~gTemFlag; + return gTemFlag ? 0xFF : 0xD9; + } + } + + gInBufLeft--; + return gInBuf[gInBufOfs++]; +} +//------------------------------------------------------------------------------ +static PJPG_INLINE void stuffChar(uint8 i) { + gInBufOfs--; + gInBuf[gInBufOfs] = i; + gInBufLeft++; +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getOctet(uint8 FFCheck) { + uint8 c = getChar(); + + if ((FFCheck) && (c == 0xFF)) { + uint8 n = getChar(); + + if (n) { + stuffChar(n); + stuffChar(0xFF); + } + } + + return c; +} +//------------------------------------------------------------------------------ +static uint16 getBits(uint8 numBits, uint8 FFCheck) { + uint8 origBits = numBits; + uint16 ret = gBitBuf; + + if (numBits > 8) { + numBits -= 8; + + gBitBuf <<= gBitsLeft; + + gBitBuf |= getOctet(FFCheck); + + gBitBuf <<= (8 - gBitsLeft); + + ret = (ret & 0xFF00) | (gBitBuf >> 8); + } + + if (gBitsLeft < numBits) { + gBitBuf <<= gBitsLeft; + + gBitBuf |= getOctet(FFCheck); + + gBitBuf <<= (numBits - gBitsLeft); + + gBitsLeft = 8 - (numBits - gBitsLeft); + } else { + gBitsLeft = (uint8)(gBitsLeft - numBits); + gBitBuf <<= numBits; + } + + return ret >> (16 - origBits); +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint16 getBits1(uint8 numBits) { return getBits(numBits, 0); } +//------------------------------------------------------------------------------ +static PJPG_INLINE uint16 getBits2(uint8 numBits) { return getBits(numBits, 1); } +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getBit(void) { + uint8 ret = 0; + if (gBitBuf & 0x8000) ret = 1; + + if (!gBitsLeft) { + gBitBuf |= getOctet(1); + + gBitsLeft += 8; + } + + gBitsLeft--; + gBitBuf <<= 1; + + return ret; +} +//------------------------------------------------------------------------------ +static uint16 getExtendTest(uint8 i) { + switch (i) { + case 0: + return 0; + case 1: + return 0x0001; + case 2: + return 0x0002; + case 3: + return 0x0004; + case 4: + return 0x0008; + case 5: + return 0x0010; + case 6: + return 0x0020; + case 7: + return 0x0040; + case 8: + return 0x0080; + case 9: + return 0x0100; + case 10: + return 0x0200; + case 11: + return 0x0400; + case 12: + return 0x0800; + case 13: + return 0x1000; + case 14: + return 0x2000; + case 15: + return 0x4000; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static int16 getExtendOffset(uint8 i) { + switch (i) { + case 0: + return 0; + case 1: + return ((-1) << 1) + 1; + case 2: + return ((-1) << 2) + 1; + case 3: + return ((-1) << 3) + 1; + case 4: + return ((-1) << 4) + 1; + case 5: + return ((-1) << 5) + 1; + case 6: + return ((-1) << 6) + 1; + case 7: + return ((-1) << 7) + 1; + case 8: + return ((-1) << 8) + 1; + case 9: + return ((-1) << 9) + 1; + case 10: + return ((-1) << 10) + 1; + case 11: + return ((-1) << 11) + 1; + case 12: + return ((-1) << 12) + 1; + case 13: + return ((-1) << 13) + 1; + case 14: + return ((-1) << 14) + 1; + case 15: + return ((-1) << 15) + 1; + default: + return 0; + } +}; +//------------------------------------------------------------------------------ +static PJPG_INLINE int16 huffExtend(uint16 x, uint8 s) { + return ((x < getExtendTest(s)) ? ((int16)x + getExtendOffset(s)) : (int16)x); +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 huffDecode(const HuffTable* pHuffTable, const uint8* pHuffVal) { + uint8 i = 0; + uint8 j; + uint16 code = getBit(); + + // This func only reads a bit at a time, which on modern CPU's is not terribly efficient. + // But on microcontrollers without strong integer shifting support this seems like a + // more reasonable approach. + for (;;) { + uint16 maxCode; + + if (i == 16) return 0; + + maxCode = pHuffTable->mMaxCode[i]; + if ((code <= maxCode) && (maxCode != 0xFFFF)) break; + + i++; + code <<= 1; + code |= getBit(); + } + + j = pHuffTable->mValPtr[i]; + j = (uint8)(j + (code - pHuffTable->mMinCode[i])); + + return pHuffVal[j]; +} +//------------------------------------------------------------------------------ +static void huffCreate(const uint8* pBits, HuffTable* pHuffTable) { + uint8 i = 0; + uint8 j = 0; + + uint16 code = 0; + + for (;;) { + uint8 num = pBits[i]; + + if (!num) { + pHuffTable->mMinCode[i] = 0x0000; + pHuffTable->mMaxCode[i] = 0xFFFF; + pHuffTable->mValPtr[i] = 0; + } else { + pHuffTable->mMinCode[i] = code; + pHuffTable->mMaxCode[i] = code + num - 1; + pHuffTable->mValPtr[i] = j; + + j = (uint8)(j + num); + + code = (uint16)(code + num); + } + + code <<= 1; + + i++; + if (i > 15) break; + } +} +//------------------------------------------------------------------------------ +static HuffTable* getHuffTable(uint8 index) { + // 0-1 = DC + // 2-3 = AC + switch (index) { + case 0: + return &gHuffTab0; + case 1: + return &gHuffTab1; + case 2: + return &gHuffTab2; + case 3: + return &gHuffTab3; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static uint8* getHuffVal(uint8 index) { + // 0-1 = DC + // 2-3 = AC + switch (index) { + case 0: + return gHuffVal0; + case 1: + return gHuffVal1; + case 2: + return gHuffVal2; + case 3: + return gHuffVal3; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static uint16 getMaxHuffCodes(uint8 index) { return (index < 2) ? 12 : 255; } +//------------------------------------------------------------------------------ +static uint8 readDHTMarker(void) { + uint8 bits[16]; + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_DHT_MARKER; + + left -= 2; + + while (left) { + uint8 i, tableIndex, index; + uint8* pHuffVal; + HuffTable* pHuffTable; + uint16 count, totalRead; + + index = (uint8)getBits1(8); + + if (((index & 0xF) > 1) || ((index & 0xF0) > 0x10)) return PJPG_BAD_DHT_INDEX; + + tableIndex = ((index >> 3) & 2) + (index & 1); + + pHuffTable = getHuffTable(tableIndex); + pHuffVal = getHuffVal(tableIndex); + + gValidHuffTables |= (1 << tableIndex); + + count = 0; + for (i = 0; i <= 15; i++) { + uint8 n = (uint8)getBits1(8); + bits[i] = n; + count = (uint16)(count + n); + } + + if (count > getMaxHuffCodes(tableIndex)) return PJPG_BAD_DHT_COUNTS; + + for (i = 0; i < count; i++) pHuffVal[i] = (uint8)getBits1(8); + + totalRead = 1 + 16 + count; + + if (left < totalRead) return PJPG_BAD_DHT_MARKER; + + left = (uint16)(left - totalRead); + + huffCreate(bits, pHuffTable); + } + + return 0; +} +//------------------------------------------------------------------------------ +static void createWinogradQuant(int16* pQuant); + +static uint8 readDQTMarker(void) { + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_DQT_MARKER; + + left -= 2; + + while (left) { + uint8 i; + uint8 n = (uint8)getBits1(8); + uint8 prec = n >> 4; + uint16 totalRead; + + n &= 0x0F; + + if (n > 1) return PJPG_BAD_DQT_TABLE; + + gValidQuantTables |= (n ? 2 : 1); + + // read quantization entries, in zag order + for (i = 0; i < 64; i++) { + uint16 temp = getBits1(8); + + if (prec) temp = (temp << 8) + getBits1(8); + + if (n) + gQuant1[i] = (int16)temp; + else + gQuant0[i] = (int16)temp; + } + + createWinogradQuant(n ? gQuant1 : gQuant0); + + totalRead = 64 + 1; + + if (prec) totalRead += 64; + + if (left < totalRead) return PJPG_BAD_DQT_LENGTH; + + left = (uint16)(left - totalRead); + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 readSOFMarker(void) { + uint8 i; + uint16 left = getBits1(16); + + if (getBits1(8) != 8) return PJPG_BAD_PRECISION; + + gImageYSize = getBits1(16); + + if ((!gImageYSize) || (gImageYSize > PJPG_MAX_HEIGHT)) return PJPG_BAD_HEIGHT; + + gImageXSize = getBits1(16); + + if ((!gImageXSize) || (gImageXSize > PJPG_MAX_WIDTH)) return PJPG_BAD_WIDTH; + + gCompsInFrame = (uint8)getBits1(8); + + if (gCompsInFrame > 3) return PJPG_TOO_MANY_COMPONENTS; + + if (left != (gCompsInFrame + gCompsInFrame + gCompsInFrame + 8)) return PJPG_BAD_SOF_LENGTH; + + for (i = 0; i < gCompsInFrame; i++) { + gCompIdent[i] = (uint8)getBits1(8); + gCompHSamp[i] = (uint8)getBits1(4); + gCompVSamp[i] = (uint8)getBits1(4); + gCompQuant[i] = (uint8)getBits1(8); + + if (gCompQuant[i] > 1) return PJPG_UNSUPPORTED_QUANT_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +// Used to skip unrecognized markers. +static uint8 skipVariableMarker(void) { + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_VARIABLE_MARKER; + + left -= 2; + + while (left) { + getBits1(8); + left--; + } + + return 0; +} +//------------------------------------------------------------------------------ +// Read a define restart interval (DRI) marker. +static uint8 readDRIMarker(void) { + if (getBits1(16) != 4) return PJPG_BAD_DRI_LENGTH; + + gRestartInterval = getBits1(16); + + return 0; +} +//------------------------------------------------------------------------------ +// Read a start of scan (SOS) marker. +static uint8 readSOSMarker(void) { + uint8 i; + uint16 left = getBits1(16); + uint8 spectral_start, spectral_end, successive_high, successive_low; + + gCompsInScan = (uint8)getBits1(8); + + left -= 3; + + if ((left != (gCompsInScan + gCompsInScan + 3)) || (gCompsInScan < 1) || (gCompsInScan > PJPG_MAXCOMPSINSCAN)) + return PJPG_BAD_SOS_LENGTH; + + for (i = 0; i < gCompsInScan; i++) { + uint8 cc = (uint8)getBits1(8); + uint8 c = (uint8)getBits1(8); + uint8 ci; + + left -= 2; + + for (ci = 0; ci < gCompsInFrame; ci++) + if (cc == gCompIdent[ci]) break; + + if (ci >= gCompsInFrame) return PJPG_BAD_SOS_COMP_ID; + + gCompList[i] = ci; + gCompDCTab[ci] = (c >> 4) & 15; + gCompACTab[ci] = (c & 15); + } + + spectral_start = (uint8)getBits1(8); + spectral_end = (uint8)getBits1(8); + successive_high = (uint8)getBits1(4); + successive_low = (uint8)getBits1(4); + + left -= 3; + + while (left) { + getBits1(8); + left--; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 nextMarker(void) { + uint8 c; + uint8 bytes = 0; + + do { + do { + bytes++; + + c = (uint8)getBits1(8); + + } while (c != 0xFF); + + do { + c = (uint8)getBits1(8); + + } while (c == 0xFF); + + } while (c == 0); + + // If bytes > 0 here, there where extra bytes before the marker (not good). + + return c; +} +//------------------------------------------------------------------------------ +// Process markers. Returns when an SOFx, SOI, EOI, or SOS marker is +// encountered. +static uint8 processMarkers(uint8* pMarker) { + for (;;) { + uint8 c = nextMarker(); + + switch (c) { + case M_SOF0: + case M_SOF1: + case M_SOF2: + case M_SOF3: + case M_SOF5: + case M_SOF6: + case M_SOF7: + // case M_JPG: + case M_SOF9: + case M_SOF10: + case M_SOF11: + case M_SOF13: + case M_SOF14: + case M_SOF15: + case M_SOI: + case M_EOI: + case M_SOS: { + *pMarker = c; + return 0; + } + case M_DHT: { + readDHTMarker(); + break; + } + // Sorry, no arithmetic support at this time. Dumb patents! + case M_DAC: { + return PJPG_NO_ARITHMITIC_SUPPORT; + } + case M_DQT: { + readDQTMarker(); + break; + } + case M_DRI: { + readDRIMarker(); + break; + } + // case M_APP0: /* no need to read the JFIF marker */ + + case M_JPG: + case M_RST0: /* no parameters */ + case M_RST1: + case M_RST2: + case M_RST3: + case M_RST4: + case M_RST5: + case M_RST6: + case M_RST7: + case M_TEM: { + return PJPG_UNEXPECTED_MARKER; + } + default: /* must be DNL, DHP, EXP, APPn, JPGn, COM, or RESn or APP0 */ + { + skipVariableMarker(); + break; + } + } + } + // return 0; +} +//------------------------------------------------------------------------------ +// Finds the start of image (SOI) marker. +static uint8 locateSOIMarker(void) { + uint16 bytesleft; + + uint8 lastchar = (uint8)getBits1(8); + + uint8 thischar = (uint8)getBits1(8); + + /* ok if it's a normal JPEG file without a special header */ + + if ((lastchar == 0xFF) && (thischar == M_SOI)) return 0; + + bytesleft = 4096; // 512; + + for (;;) { + if (--bytesleft == 0) return PJPG_NOT_JPEG; + + lastchar = thischar; + + thischar = (uint8)getBits1(8); + + if (lastchar == 0xFF) { + if (thischar == M_SOI) + break; + else if (thischar == M_EOI) // getBits1 will keep returning M_EOI if we read past the end + return PJPG_NOT_JPEG; + } + } + + /* Check the next character after marker: if it's not 0xFF, it can't + be the start of the next marker, so the file is bad */ + + thischar = (uint8)((gBitBuf >> 8) & 0xFF); + + if (thischar != 0xFF) return PJPG_NOT_JPEG; + + return 0; +} +//------------------------------------------------------------------------------ +// Find a start of frame (SOF) marker. +static uint8 locateSOFMarker(void) { + uint8 c; + + uint8 status = locateSOIMarker(); + if (status) return status; + + status = processMarkers(&c); + if (status) return status; + + switch (c) { + case M_SOF2: { + // Progressive JPEG - not supported by picojpeg (would require too + // much memory, or too many IDCT's for embedded systems). + return PJPG_UNSUPPORTED_MODE; + } + case M_SOF0: /* baseline DCT */ + { + status = readSOFMarker(); + if (status) return status; + + break; + } + case M_SOF9: { + return PJPG_NO_ARITHMITIC_SUPPORT; + } + case M_SOF1: /* extended sequential DCT */ + default: { + return PJPG_UNSUPPORTED_MARKER; + } + } + + return 0; +} +//------------------------------------------------------------------------------ +// Find a start of scan (SOS) marker. +static uint8 locateSOSMarker(uint8* pFoundEOI) { + uint8 c; + uint8 status; + + *pFoundEOI = 0; + + status = processMarkers(&c); + if (status) return status; + + if (c == M_EOI) { + *pFoundEOI = 1; + return 0; + } else if (c != M_SOS) + return PJPG_UNEXPECTED_MARKER; + + return readSOSMarker(); +} +//------------------------------------------------------------------------------ +static uint8 init(void) { + gImageXSize = 0; + gImageYSize = 0; + gCompsInFrame = 0; + gRestartInterval = 0; + gCompsInScan = 0; + gValidHuffTables = 0; + gValidQuantTables = 0; + gTemFlag = 0; + gInBufOfs = 0; + gInBufLeft = 0; + gBitBuf = 0; + gBitsLeft = 8; + + getBits1(8); + getBits1(8); + + return 0; +} +//------------------------------------------------------------------------------ +// This method throws back into the stream any bytes that where read +// into the bit buffer during initial marker scanning. +static void fixInBuffer(void) { + /* In case any 0xFF's where pulled into the buffer during marker scanning */ + + if (gBitsLeft > 0) stuffChar((uint8)gBitBuf); + + stuffChar((uint8)(gBitBuf >> 8)); + + gBitsLeft = 8; + getBits2(8); + getBits2(8); +} +//------------------------------------------------------------------------------ +// Restart interval processing. +static uint8 processRestart(void) { + // Let's scan a little bit to find the marker, but not _too_ far. + // 1536 is a "fudge factor" that determines how much to scan. + uint16 i; + uint8 c = 0; + + for (i = 1536; i > 0; i--) + if (getChar() == 0xFF) break; + + if (i == 0) return PJPG_BAD_RESTART_MARKER; + + for (; i > 0; i--) + if ((c = getChar()) != 0xFF) break; + + if (i == 0) return PJPG_BAD_RESTART_MARKER; + + // Is it the expected marker? If not, something bad happened. + if (c != (gNextRestartNum + M_RST0)) return PJPG_BAD_RESTART_MARKER; + + // Reset each component's DC prediction values. + gLastDC[0] = 0; + gLastDC[1] = 0; + gLastDC[2] = 0; + + gRestartsLeft = gRestartInterval; + + gNextRestartNum = (gNextRestartNum + 1) & 7; + + // Get the bit buffer going again + + gBitsLeft = 8; + getBits2(8); + getBits2(8); + + return 0; +} +//------------------------------------------------------------------------------ +// FIXME: findEOI() is not actually called at the end of the image +// (it's optional, and probably not needed on embedded devices) +static uint8 findEOI(void) { + uint8 c; + uint8 status; + + // Prime the bit buffer + gBitsLeft = 8; + getBits1(8); + getBits1(8); + + // The next marker _should_ be EOI + status = processMarkers(&c); + if (status) + return status; + else if (gCallbackStatus) + return gCallbackStatus; + + // gTotalBytesRead -= in_buf_left; + if (c != M_EOI) return PJPG_UNEXPECTED_MARKER; + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 checkHuffTables(void) { + uint8 i; + + for (i = 0; i < gCompsInScan; i++) { + uint8 compDCTab = gCompDCTab[gCompList[i]]; + uint8 compACTab = gCompACTab[gCompList[i]] + 2; + + if (((gValidHuffTables & (1 << compDCTab)) == 0) || ((gValidHuffTables & (1 << compACTab)) == 0)) + return PJPG_UNDEFINED_HUFF_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 checkQuantTables(void) { + uint8 i; + + for (i = 0; i < gCompsInScan; i++) { + uint8 compQuantMask = gCompQuant[gCompList[i]] ? 2 : 1; + + if ((gValidQuantTables & compQuantMask) == 0) return PJPG_UNDEFINED_QUANT_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 initScan(void) { + uint8 foundEOI; + uint8 status = locateSOSMarker(&foundEOI); + if (status) return status; + if (foundEOI) return PJPG_UNEXPECTED_MARKER; + + status = checkHuffTables(); + if (status) return status; + + status = checkQuantTables(); + if (status) return status; + + gLastDC[0] = 0; + gLastDC[1] = 0; + gLastDC[2] = 0; + + if (gRestartInterval) { + gRestartsLeft = gRestartInterval; + gNextRestartNum = 0; + } + + fixInBuffer(); + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 initFrame(void) { + if (gCompsInFrame == 1) { + if ((gCompHSamp[0] != 1) || (gCompVSamp[0] != 1)) return PJPG_UNSUPPORTED_SAMP_FACTORS; + + gScanType = PJPG_GRAYSCALE; + + gMaxBlocksPerMCU = 1; + gMCUOrg[0] = 0; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 8; + } else if (gCompsInFrame == 3) { + if (((gCompHSamp[1] != 1) || (gCompVSamp[1] != 1)) || ((gCompHSamp[2] != 1) || (gCompVSamp[2] != 1))) + return PJPG_UNSUPPORTED_SAMP_FACTORS; + + if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 1)) { + gScanType = PJPG_YH1V1; + + gMaxBlocksPerMCU = 3; + gMCUOrg[0] = 0; + gMCUOrg[1] = 1; + gMCUOrg[2] = 2; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 8; + } else if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 2)) { + gScanType = PJPG_YH1V2; + + gMaxBlocksPerMCU = 4; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 1; + gMCUOrg[3] = 2; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 16; + } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 1)) { + gScanType = PJPG_YH2V1; + + gMaxBlocksPerMCU = 4; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 1; + gMCUOrg[3] = 2; + + gMaxMCUXSize = 16; + gMaxMCUYSize = 8; + } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 2)) { + gScanType = PJPG_YH2V2; + + gMaxBlocksPerMCU = 6; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 0; + gMCUOrg[3] = 0; + gMCUOrg[4] = 1; + gMCUOrg[5] = 2; + + gMaxMCUXSize = 16; + gMaxMCUYSize = 16; + } else + return PJPG_UNSUPPORTED_SAMP_FACTORS; + } else + return PJPG_UNSUPPORTED_COLORSPACE; + + gMaxMCUSPerRow = (gImageXSize + (gMaxMCUXSize - 1)) >> ((gMaxMCUXSize == 8) ? 3 : 4); + gMaxMCUSPerCol = (gImageYSize + (gMaxMCUYSize - 1)) >> ((gMaxMCUYSize == 8) ? 3 : 4); + + // This can overflow on large JPEG's. + // gNumMCUSRemaining = gMaxMCUSPerRow * gMaxMCUSPerCol; + gNumMCUSRemainingX = gMaxMCUSPerRow; + gNumMCUSRemainingY = gMaxMCUSPerCol; + + return 0; +} +//---------------------------------------------------------------------------- +// Winograd IDCT: 5 multiplies per row/col, up to 80 muls for the 2D IDCT + +#define PJPG_DCT_SCALE_BITS 7 + +#define PJPG_DCT_SCALE (1U << PJPG_DCT_SCALE_BITS) + +#define PJPG_DESCALE(x) PJPG_ARITH_SHIFT_RIGHT_N_16(((x) + (1 << (PJPG_DCT_SCALE_BITS - 1))), PJPG_DCT_SCALE_BITS) + +#define PJPG_WFIX(x) ((x) * PJPG_DCT_SCALE + 0.5f) + +#define PJPG_WINOGRAD_QUANT_SCALE_BITS 10 + +const uint8 gWinogradQuant[] = { + 128, 178, 178, 167, 246, 167, 151, 232, 232, 151, 128, 209, 219, 209, 128, 101, 178, 197, 197, 178, 101, 69, + 139, 167, 177, 167, 139, 69, 35, 96, 131, 151, 151, 131, 96, 35, 49, 91, 118, 128, 118, 91, 49, 46, + 81, 101, 101, 81, 46, 42, 69, 79, 69, 42, 35, 54, 54, 35, 28, 37, 28, 19, 19, 10, +}; + +// Multiply quantization matrix by the Winograd IDCT scale factors +static void createWinogradQuant(int16* pQuant) { + uint8 i; + + for (i = 0; i < 64; i++) { + long x = pQuant[i]; + x *= gWinogradQuant[i]; + pQuant[i] = (int16)((x + (1 << (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS - 1))) >> + (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS)); + } +} + +// These multiply helper functions are the 4 types of signed multiplies needed by the Winograd IDCT. +// A smart C compiler will optimize them to use 16x8 = 24 bit muls, if not you may need to tweak +// these functions or drop to CPU specific inline assembly. + +// 1/cos(4*pi/16) +// 362, 256+106 +static PJPG_INLINE int16 imul_b1_b3(int16 w) { + long x = (w * 362L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/cos(6*pi/16) +// 669, 256+256+157 +static PJPG_INLINE int16 imul_b2(int16 w) { + long x = (w * 669L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/cos(2*pi/16) +// 277, 256+21 +static PJPG_INLINE int16 imul_b4(int16 w) { + long x = (w * 277L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/(cos(2*pi/16) + cos(6*pi/16)) +// 196, 196 +static PJPG_INLINE int16 imul_b5(int16 w) { + long x = (w * 196L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +static PJPG_INLINE uint8 clamp(int16 s) { + if ((uint16)s > 255U) { + if (s < 0) + return 0; + else if (s > 255) + return 255; + } + + return (uint8)s; +} + +static void idctRows(void) { + uint8 i; + int16* pSrc = gCoeffBuf; + + for (i = 0; i < 8; i++) { + if ((pSrc[1] | pSrc[2] | pSrc[3] | pSrc[4] | pSrc[5] | pSrc[6] | pSrc[7]) == 0) { + // Short circuit the 1D IDCT if only the DC component is non-zero + int16 src0 = *pSrc; + + *(pSrc + 1) = src0; + *(pSrc + 2) = src0; + *(pSrc + 3) = src0; + *(pSrc + 4) = src0; + *(pSrc + 5) = src0; + *(pSrc + 6) = src0; + *(pSrc + 7) = src0; + } else { + int16 src4 = *(pSrc + 5); + int16 src7 = *(pSrc + 3); + int16 x4 = src4 - src7; + int16 x7 = src4 + src7; + + int16 src5 = *(pSrc + 1); + int16 src6 = *(pSrc + 7); + int16 x5 = src5 + src6; + int16 x6 = src5 - src6; + + int16 tmp1 = imul_b5(x4 - x6); + int16 stg26 = imul_b4(x6) - tmp1; + + int16 x24 = tmp1 - imul_b2(x4); + + int16 x15 = x5 - x7; + int16 x17 = x5 + x7; + + int16 tmp2 = stg26 - x17; + int16 tmp3 = imul_b1_b3(x15) - tmp2; + int16 x44 = tmp3 + x24; + + int16 src0 = *(pSrc + 0); + int16 src1 = *(pSrc + 4); + int16 x30 = src0 + src1; + int16 x31 = src0 - src1; + + int16 src2 = *(pSrc + 2); + int16 src3 = *(pSrc + 6); + int16 x12 = src2 - src3; + int16 x13 = src2 + src3; + + int16 x32 = imul_b1_b3(x12) - x13; + + int16 x40 = x30 + x13; + int16 x43 = x30 - x13; + int16 x41 = x31 + x32; + int16 x42 = x31 - x32; + + *(pSrc + 0) = x40 + x17; + *(pSrc + 1) = x41 + tmp2; + *(pSrc + 2) = x42 + tmp3; + *(pSrc + 3) = x43 - x44; + *(pSrc + 4) = x43 + x44; + *(pSrc + 5) = x42 - tmp3; + *(pSrc + 6) = x41 - tmp2; + *(pSrc + 7) = x40 - x17; + } + + pSrc += 8; + } +} + +static void idctCols(void) { + uint8 i; + + int16* pSrc = gCoeffBuf; + + for (i = 0; i < 8; i++) { + if ((pSrc[1 * 8] | pSrc[2 * 8] | pSrc[3 * 8] | pSrc[4 * 8] | pSrc[5 * 8] | pSrc[6 * 8] | pSrc[7 * 8]) == 0) { + // Short circuit the 1D IDCT if only the DC component is non-zero + uint8 c = clamp(PJPG_DESCALE(*pSrc) + 128); + *(pSrc + 0 * 8) = c; + *(pSrc + 1 * 8) = c; + *(pSrc + 2 * 8) = c; + *(pSrc + 3 * 8) = c; + *(pSrc + 4 * 8) = c; + *(pSrc + 5 * 8) = c; + *(pSrc + 6 * 8) = c; + *(pSrc + 7 * 8) = c; + } else { + int16 src4 = *(pSrc + 5 * 8); + int16 src7 = *(pSrc + 3 * 8); + int16 x4 = src4 - src7; + int16 x7 = src4 + src7; + + int16 src5 = *(pSrc + 1 * 8); + int16 src6 = *(pSrc + 7 * 8); + int16 x5 = src5 + src6; + int16 x6 = src5 - src6; + + int16 tmp1 = imul_b5(x4 - x6); + int16 stg26 = imul_b4(x6) - tmp1; + + int16 x24 = tmp1 - imul_b2(x4); + + int16 x15 = x5 - x7; + int16 x17 = x5 + x7; + + int16 tmp2 = stg26 - x17; + int16 tmp3 = imul_b1_b3(x15) - tmp2; + int16 x44 = tmp3 + x24; + + int16 src0 = *(pSrc + 0 * 8); + int16 src1 = *(pSrc + 4 * 8); + int16 x30 = src0 + src1; + int16 x31 = src0 - src1; + + int16 src2 = *(pSrc + 2 * 8); + int16 src3 = *(pSrc + 6 * 8); + int16 x12 = src2 - src3; + int16 x13 = src2 + src3; + + int16 x32 = imul_b1_b3(x12) - x13; + + int16 x40 = x30 + x13; + int16 x43 = x30 - x13; + int16 x41 = x31 + x32; + int16 x42 = x31 - x32; + + // descale, convert to unsigned and clamp to 8-bit + *(pSrc + 0 * 8) = clamp(PJPG_DESCALE(x40 + x17) + 128); + *(pSrc + 1 * 8) = clamp(PJPG_DESCALE(x41 + tmp2) + 128); + *(pSrc + 2 * 8) = clamp(PJPG_DESCALE(x42 + tmp3) + 128); + *(pSrc + 3 * 8) = clamp(PJPG_DESCALE(x43 - x44) + 128); + *(pSrc + 4 * 8) = clamp(PJPG_DESCALE(x43 + x44) + 128); + *(pSrc + 5 * 8) = clamp(PJPG_DESCALE(x42 - tmp3) + 128); + *(pSrc + 6 * 8) = clamp(PJPG_DESCALE(x41 - tmp2) + 128); + *(pSrc + 7 * 8) = clamp(PJPG_DESCALE(x40 - x17) + 128); + } + + pSrc++; + } +} + +/*----------------------------------------------------------------------------*/ +static PJPG_INLINE uint8 addAndClamp(uint8 a, int16 b) { + b = a + b; + + if ((uint16)b > 255U) { + if (b < 0) + return 0; + else if (b > 255) + return 255; + } + + return (uint8)b; +} +/*----------------------------------------------------------------------------*/ +static PJPG_INLINE uint8 subAndClamp(uint8 a, int16 b) { + b = a - b; + + if ((uint16)b > 255U) { + if (b < 0) + return 0; + else if (b > 255) + return 255; + } + + return (uint8)b; +} +/*----------------------------------------------------------------------------*/ +// 103/256 +// R = Y + 1.402 (Cr-128) + +// 88/256, 183/256 +// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) + +// 198/256 +// B = Y + 1.772 (Cb-128) +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 4x4 to 8x8 +static void upsampleCb(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 4; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[1] = subAndClamp(pDstG[1], cbG); + pDstG[8] = subAndClamp(pDstG[8], cbG); + pDstG[9] = subAndClamp(pDstG[9], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[1] = addAndClamp(pDstB[1], cbB); + pDstB[8] = addAndClamp(pDstB[8], cbB); + pDstB[9] = addAndClamp(pDstB[9], cbB); + + pDstG += 2; + pDstB += 2; + } + + pSrc = pSrc - 4 + 8; + pDstG = pDstG - 8 + 16; + pDstB = pDstB - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 4x8 to 8x8 +static void upsampleCbH(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 8; y++) { + for (x = 0; x < 4; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[1] = subAndClamp(pDstG[1], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[1] = addAndClamp(pDstB[1], cbB); + + pDstG += 2; + pDstB += 2; + } + + pSrc = pSrc - 4 + 8; + } +} +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 8x4 to 8x8 +static void upsampleCbV(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 8; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[8] = subAndClamp(pDstG[8], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[8] = addAndClamp(pDstB[8], cbB); + + ++pDstG; + ++pDstB; + } + + pDstG = pDstG - 8 + 16; + pDstB = pDstB - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// 103/256 +// R = Y + 1.402 (Cr-128) + +// 88/256, 183/256 +// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) + +// 198/256 +// B = Y + 1.772 (Cb-128) +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 4x4 to 8x8 +static void upsampleCr(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 4; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[1] = addAndClamp(pDstR[1], crR); + pDstR[8] = addAndClamp(pDstR[8], crR); + pDstR[9] = addAndClamp(pDstR[9], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[1] = subAndClamp(pDstG[1], crG); + pDstG[8] = subAndClamp(pDstG[8], crG); + pDstG[9] = subAndClamp(pDstG[9], crG); + + pDstR += 2; + pDstG += 2; + } + + pSrc = pSrc - 4 + 8; + pDstR = pDstR - 8 + 16; + pDstG = pDstG - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 4x8 to 8x8 +static void upsampleCrH(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 8; y++) { + for (x = 0; x < 4; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[1] = addAndClamp(pDstR[1], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[1] = subAndClamp(pDstG[1], crG); + + pDstR += 2; + pDstG += 2; + } + + pSrc = pSrc - 4 + 8; + } +} +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 8x4 to 8x8 +static void upsampleCrV(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 8; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[8] = addAndClamp(pDstR[8], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[8] = subAndClamp(pDstG[8], crG); + + ++pDstR; + ++pDstG; + } + + pDstR = pDstR - 8 + 16; + pDstG = pDstG - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Convert Y to RGB +static void copyY(uint8 dstOfs) { + uint8 i; + uint8* pRDst = gMCUBufR + dstOfs; + uint8* pGDst = gMCUBufG + dstOfs; + uint8* pBDst = gMCUBufB + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 c = (uint8)*pSrc++; + + *pRDst++ = c; + *pGDst++ = c; + *pBDst++ = c; + } +} +/*----------------------------------------------------------------------------*/ +// Cb convert to RGB and accumulate +static void convertCb(uint8 dstOfs) { + uint8 i; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + *pDstG++ = subAndClamp(pDstG[0], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + *pDstB++ = addAndClamp(pDstB[0], cbB); + } +} +/*----------------------------------------------------------------------------*/ +// Cr convert to RGB and accumulate +static void convertCr(uint8 dstOfs) { + uint8 i; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + *pDstR++ = addAndClamp(pDstR[0], crR); + + crG = ((cr * 183U) >> 8U) - 91; + *pDstG++ = subAndClamp(pDstG[0], crG); + } +} +/*----------------------------------------------------------------------------*/ +static void transformBlock(uint8 mcuBlock) { + idctRows(); + idctCols(); + + switch (gScanType) { + case PJPG_GRAYSCALE: { + // MCU size: 1, 1 block per MCU + copyY(0); + break; + } + case PJPG_YH1V1: { + // MCU size: 8x8, 3 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + convertCb(0); + break; + } + case 2: { + convertCr(0); + break; + } + } + + break; + } + case PJPG_YH1V2: { + // MCU size: 8x16, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(128); + break; + } + case 2: { + upsampleCbV(0, 0); + upsampleCbV(4 * 8, 128); + break; + } + case 3: { + upsampleCrV(0, 0); + upsampleCrV(4 * 8, 128); + break; + } + } + + break; + } + case PJPG_YH2V1: { + // MCU size: 16x8, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(64); + break; + } + case 2: { + upsampleCbH(0, 0); + upsampleCbH(4, 64); + break; + } + case 3: { + upsampleCrH(0, 0); + upsampleCrH(4, 64); + break; + } + } + + break; + } + case PJPG_YH2V2: { + // MCU size: 16x16, 6 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(64); + break; + } + case 2: { + copyY(128); + break; + } + case 3: { + copyY(192); + break; + } + case 4: { + upsampleCb(0, 0); + upsampleCb(4, 64); + upsampleCb(4 * 8, 128); + upsampleCb(4 + 4 * 8, 192); + break; + } + case 5: { + upsampleCr(0, 0); + upsampleCr(4, 64); + upsampleCr(4 * 8, 128); + upsampleCr(4 + 4 * 8, 192); + break; + } + } + + break; + } + } +} +//------------------------------------------------------------------------------ +static void transformBlockReduce(uint8 mcuBlock) { + uint8 c = clamp(PJPG_DESCALE(gCoeffBuf[0]) + 128); + int16 cbG, cbB, crR, crG; + + switch (gScanType) { + case PJPG_GRAYSCALE: { + // MCU size: 1, 1 block per MCU + gMCUBufR[0] = c; + break; + } + case PJPG_YH1V1: { + // MCU size: 8x8, 3 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + break; + } + case 2: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + break; + } + } + + break; + } + case PJPG_YH1V2: { + // MCU size: 8x16, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[128] = c; + gMCUBufG[128] = c; + gMCUBufB[128] = c; + break; + } + case 2: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); + + break; + } + case 3: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); + + break; + } + } + break; + } + case PJPG_YH2V1: { + // MCU size: 16x8, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[64] = c; + gMCUBufG[64] = c; + gMCUBufB[64] = c; + break; + } + case 2: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); + + break; + } + case 3: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); + + break; + } + } + break; + } + case PJPG_YH2V2: { + // MCU size: 16x16, 6 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[64] = c; + gMCUBufG[64] = c; + gMCUBufB[64] = c; + break; + } + case 2: { + gMCUBufR[128] = c; + gMCUBufG[128] = c; + gMCUBufB[128] = c; + break; + } + case 3: { + gMCUBufR[192] = c; + gMCUBufG[192] = c; + gMCUBufB[192] = c; + break; + } + case 4: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); + gMCUBufG[192] = subAndClamp(gMCUBufG[192], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); + gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); + gMCUBufB[192] = addAndClamp(gMCUBufB[192], cbB); + + break; + } + case 5: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); + gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); + gMCUBufR[192] = addAndClamp(gMCUBufR[192], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); + gMCUBufG[192] = subAndClamp(gMCUBufG[192], crG); + + break; + } + } + break; + } + } +} +//------------------------------------------------------------------------------ +static uint8 decodeNextMCU(void) { + uint8 status; + uint8 mcuBlock; + + if (gRestartInterval) { + if (gRestartsLeft == 0) { + status = processRestart(); + if (status) return status; + } + gRestartsLeft--; + } + + for (mcuBlock = 0; mcuBlock < gMaxBlocksPerMCU; mcuBlock++) { + uint8 componentID = gMCUOrg[mcuBlock]; + uint8 compQuant = gCompQuant[componentID]; + uint8 compDCTab = gCompDCTab[componentID]; + uint8 numExtraBits, compACTab, k; + const int16* pQ = compQuant ? gQuant1 : gQuant0; + uint16 r, dc; + + uint8 s = huffDecode(compDCTab ? &gHuffTab1 : &gHuffTab0, compDCTab ? gHuffVal1 : gHuffVal0); + + r = 0; + numExtraBits = s & 0xF; + if (numExtraBits) r = getBits2(numExtraBits); + dc = huffExtend(r, s); + + dc = dc + gLastDC[componentID]; + gLastDC[componentID] = dc; + + gCoeffBuf[0] = dc * pQ[0]; + + compACTab = gCompACTab[componentID]; + + if (gReduce) { + // Decode, but throw out the AC coefficients in reduce mode. + for (k = 1; k < 64; k++) { + s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); + + numExtraBits = s & 0xF; + if (numExtraBits) getBits2(numExtraBits); + + r = s >> 4; + s &= 15; + + if (s) { + if (r) { + if ((k + r) > 63) return PJPG_DECODE_ERROR; + + k = (uint8)(k + r); + } + } else { + if (r == 15) { + if ((k + 16) > 64) return PJPG_DECODE_ERROR; + + k += (16 - 1); // - 1 because the loop counter is k + } else + break; + } + } + + transformBlockReduce(mcuBlock); + } else { + // Decode and dequantize AC coefficients + for (k = 1; k < 64; k++) { + uint16 extraBits; + + s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); + + extraBits = 0; + numExtraBits = s & 0xF; + if (numExtraBits) extraBits = getBits2(numExtraBits); + + r = s >> 4; + s &= 15; + + if (s) { + int16 ac; + + if (r) { + if ((k + r) > 63) return PJPG_DECODE_ERROR; + + while (r) { + gCoeffBuf[ZAG[k++]] = 0; + r--; + } + } + + ac = huffExtend(extraBits, s); + + gCoeffBuf[ZAG[k]] = ac * pQ[k]; + } else { + if (r == 15) { + if ((k + 16) > 64) return PJPG_DECODE_ERROR; + + for (r = 16; r > 0; r--) gCoeffBuf[ZAG[k++]] = 0; + + k--; // - 1 because the loop counter is k + } else + break; + } + } + + while (k < 64) gCoeffBuf[ZAG[k++]] = 0; + + transformBlock(mcuBlock); + } + } + + return 0; +} +//------------------------------------------------------------------------------ +unsigned char pjpeg_decode_mcu(void) { + uint8 status; + + if (gCallbackStatus) return gCallbackStatus; + + if ((!gNumMCUSRemainingX) && (!gNumMCUSRemainingY)) return PJPG_NO_MORE_BLOCKS; + + status = decodeNextMCU(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + gNumMCUSRemainingX--; + if (!gNumMCUSRemainingX) { + gNumMCUSRemainingY--; + if (gNumMCUSRemainingY > 0) gNumMCUSRemainingX = gMaxMCUSPerRow; + } + + return 0; +} +//------------------------------------------------------------------------------ +unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, + void* pCallback_data, unsigned char reduce) { + uint8 status; + + pInfo->m_width = 0; + pInfo->m_height = 0; + pInfo->m_comps = 0; + pInfo->m_MCUSPerRow = 0; + pInfo->m_MCUSPerCol = 0; + pInfo->m_scanType = PJPG_GRAYSCALE; + pInfo->m_MCUWidth = 0; + pInfo->m_MCUHeight = 0; + pInfo->m_pMCUBufR = (unsigned char*)0; + pInfo->m_pMCUBufG = (unsigned char*)0; + pInfo->m_pMCUBufB = (unsigned char*)0; + + g_pNeedBytesCallback = pNeed_bytes_callback; + g_pCallback_data = pCallback_data; + gCallbackStatus = 0; + gReduce = reduce; + + status = init(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = locateSOFMarker(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = initFrame(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = initScan(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + pInfo->m_width = gImageXSize; + pInfo->m_height = gImageYSize; + pInfo->m_comps = gCompsInFrame; + pInfo->m_scanType = gScanType; + pInfo->m_MCUSPerRow = gMaxMCUSPerRow; + pInfo->m_MCUSPerCol = gMaxMCUSPerCol; + pInfo->m_MCUWidth = gMaxMCUXSize; + pInfo->m_MCUHeight = gMaxMCUYSize; + pInfo->m_pMCUBufR = gMCUBufR; + pInfo->m_pMCUBufG = gMCUBufG; + pInfo->m_pMCUBufB = gMCUBufB; + + return 0; +} diff --git a/lib/picojpeg/picojpeg.h b/lib/picojpeg/picojpeg.h new file mode 100644 index 00000000..11345fb7 --- /dev/null +++ b/lib/picojpeg/picojpeg.h @@ -0,0 +1,124 @@ +//------------------------------------------------------------------------------ +// picojpeg - Public domain, Rich Geldreich +//------------------------------------------------------------------------------ +#ifndef PICOJPEG_H +#define PICOJPEG_H + +#ifdef __cplusplus +extern "C" { +#endif + +// Error codes +enum { + PJPG_NO_MORE_BLOCKS = 1, + PJPG_BAD_DHT_COUNTS, + PJPG_BAD_DHT_INDEX, + PJPG_BAD_DHT_MARKER, + PJPG_BAD_DQT_MARKER, + PJPG_BAD_DQT_TABLE, + PJPG_BAD_PRECISION, + PJPG_BAD_HEIGHT, + PJPG_BAD_WIDTH, + PJPG_TOO_MANY_COMPONENTS, + PJPG_BAD_SOF_LENGTH, + PJPG_BAD_VARIABLE_MARKER, + PJPG_BAD_DRI_LENGTH, + PJPG_BAD_SOS_LENGTH, + PJPG_BAD_SOS_COMP_ID, + PJPG_W_EXTRA_BYTES_BEFORE_MARKER, + PJPG_NO_ARITHMITIC_SUPPORT, + PJPG_UNEXPECTED_MARKER, + PJPG_NOT_JPEG, + PJPG_UNSUPPORTED_MARKER, + PJPG_BAD_DQT_LENGTH, + PJPG_TOO_MANY_BLOCKS, + PJPG_UNDEFINED_QUANT_TABLE, + PJPG_UNDEFINED_HUFF_TABLE, + PJPG_NOT_SINGLE_SCAN, + PJPG_UNSUPPORTED_COLORSPACE, + PJPG_UNSUPPORTED_SAMP_FACTORS, + PJPG_DECODE_ERROR, + PJPG_BAD_RESTART_MARKER, + PJPG_ASSERTION_ERROR, + PJPG_BAD_SOS_SPECTRAL, + PJPG_BAD_SOS_SUCCESSIVE, + PJPG_STREAM_READ_ERROR, + PJPG_NOTENOUGHMEM, + PJPG_UNSUPPORTED_COMP_IDENT, + PJPG_UNSUPPORTED_QUANT_TABLE, + PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's +}; + +// Scan types +typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t; + +typedef struct { + // Image resolution + int m_width; + int m_height; + + // Number of components (1 or 3) + int m_comps; + + // Total number of minimum coded units (MCU's) per row/col. + int m_MCUSPerRow; + int m_MCUSPerCol; + + // Scan type + pjpeg_scan_type_t m_scanType; + + // MCU width/height in pixels (each is either 8 or 16 depending on the scan type) + int m_MCUWidth; + int m_MCUHeight; + + // m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers. + // Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB + // pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for + // H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single + // component: either Y for grayscale images, or R, G or B components for color images. + // + // The 8x8 pixel blocks are organized in these byte arrays like this: + // + // PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels. + // Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to + // bottom) from the 8x8 block. + // + // PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels. + // + // PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels. + // The 2 RGB blocks are at byte offsets: 0, 64 + // + // PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels. + // The 2 RGB blocks are at byte offsets: 0, + // 128 + // + // PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels. + // The 2x2 block array is organized at byte offsets: 0, 64, + // 128, 192 + // + // It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap. + unsigned char* m_pMCUBufR; + unsigned char* m_pMCUBufG; + unsigned char* m_pMCUBufB; +} pjpeg_image_info_t; + +typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data); + +// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure. +// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer. +// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC +// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe. +unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, + void* pCallback_data, unsigned char reduce); + +// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an +// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread +// safe. +unsigned char pjpeg_decode_mcu(void); + +#ifdef __cplusplus +} +#endif + +#endif // PICOJPEG_H From 6aa5d41a429f9ba40c0c561d3cc81ffd4eb5a7aa Mon Sep 17 00:00:00 2001 From: Sam Davis Date: Sun, 21 Dec 2025 18:32:50 +1100 Subject: [PATCH 12/33] Add info about sleep screen customisation to user guide (#88) ## Summary - Updates user guide with information about using custom sleep screens ## Additional Context N/A --- USER_GUIDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f5a9bd04..333fa727 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -64,6 +64,18 @@ The Settings screen allows you to configure the device's behavior. There are a f paragraphs will not have vertical space between them, but will have first word indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. +### 3.6 Sleep Screen + +You can customize the sleep screen by placing custom images in specific locations on the SD card: + +- **Single Image:** Place a file named `sleep.bmp` in the root directory. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be randomly selected each time the device sleeps. + +> [!TIP] +> For best results: +> - Use uncompressed BMP files with 24-bit color depth +> - Use a resolution of 480x800 pixels to match the device's screen resolution. + --- ## 4. Reading Mode From 958508eb6bf53c0b2ec6443a08937dc188948e81 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 18:41:52 +1100 Subject: [PATCH 13/33] Prevent boot loop if last open epub crashes on load (#87) ## Summary * Unset openEpubPath on boot and set once epub fully loaded ## Additional Context * If an epub was crashing when loading, it was possible to get the device stuck into a loop. There was no way to get back to the home screen as we'd always load you back into old epub * Break this loop by clearing the stored value when we boot, still jumping to the last open epub, but only resetting that value once the epub has been fully loaded --- src/activities/reader/EpubReaderActivity.cpp | 5 +++++ src/activities/reader/ReaderActivity.cpp | 3 --- src/main.cpp | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8827e998..9635952e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -6,6 +6,7 @@ #include "Battery.h" #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "config.h" @@ -44,6 +45,10 @@ void EpubReaderActivity::onEnter() { f.close(); } + // Save current epub as last opened epub + APP_STATE.openEpubPath = epub->getPath(); + APP_STATE.saveToFile(); + // Trigger first update updateRequired = true; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 099d7e2c..93389fe7 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -2,7 +2,6 @@ #include -#include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" @@ -29,8 +28,6 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) { auto epub = loadEpub(path); if (epub) { - APP_STATE.openEpubPath = path; - APP_STATE.saveToFile(); onGoToEpubReader(std::move(epub)); } else { exitActivity(); diff --git a/src/main.cpp b/src/main.cpp index 89d2af48..5dfc25ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -194,7 +194,11 @@ void setup() { if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else { - onGoToReader(APP_STATE.openEpubPath); + // Clear app state to avoid getting into a boot loop if the epub doesn't load + const auto path = APP_STATE.openEpubPath; + APP_STATE.openEpubPath = ""; + APP_STATE.saveToFile(); + onGoToReader(path); } // Ensure we're not still holding the power button before leaving setup From 955c78de649aaa39f2b8e357934dfe55c7ff9055 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 18:42:06 +1100 Subject: [PATCH 14/33] Book cover sleep screen (#89) ## Summary * Fix issue with 2-bit bmp rendering * Add support generate book cover BMP from JPG and use as sleep screen ## Additional Context * It does not support other image formats beyond JPG at this point * Something is cooked with my JpegToBmpConverter logic, it generates weird interlaced looking images for some JPGs | Book 1 | Book 2| | --- | --- | | ![IMG_5653](https://github.com/user-attachments/assets/49bbaeaa-b171-44c7-a68d-14cbe42aef03) | ![IMG_5652](https://github.com/user-attachments/assets/7db88d70-e09a-49b0-a9a0-4cc729b4ca0c) | --- USER_GUIDE.md | 13 ++- lib/Epub/Epub.cpp | 41 +++++++- lib/Epub/Epub.h | 3 +- lib/GfxRenderer/Bitmap.cpp | 39 +++++--- src/CrossPointSettings.cpp | 4 +- src/CrossPointSettings.h | 5 +- src/activities/boot_sleep/SleepActivity.cpp | 98 ++++++++++++++------ src/activities/boot_sleep/SleepActivity.h | 6 +- src/activities/settings/SettingsActivity.cpp | 35 ++++--- src/activities/settings/SettingsActivity.h | 9 +- 10 files changed, 182 insertions(+), 71 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 333fa727..a19a1e8d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -59,7 +59,11 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: -- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen +- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: + - "Dark" (default) - The default dark sleep screen + - "Light" - The same default sleep screen, on a white background + - "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information + - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, paragraphs will not have vertical space between them, but will have first word indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. @@ -69,7 +73,12 @@ The Settings screen allows you to configure the device's behavior. There are a f You can customize the sleep screen by placing custom images in specific locations on the SD card: - **Single Image:** Place a file named `sleep.bmp` in the root directory. -- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be randomly selected each time the device sleeps. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images + inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be + randomly selected each time the device sleeps. + +> [!NOTE] +> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images. > [!TIP] > For best results: diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index cc3bc909..d959cb79 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,6 +1,7 @@ #include "Epub.h" #include +#include #include #include @@ -218,7 +219,45 @@ const std::string& Epub::getPath() const { return filepath; } const std::string& Epub::getTitle() const { return title; } -const std::string& Epub::getCoverImageItem() const { return coverImageItem; } +std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Epub::generateCoverBmp() const { + // Already generated, return true + if (SD.exists(getCoverBmpPath().c_str())) { + return true; + } + + if (coverImageItem.empty()) { + Serial.printf("[%lu] [EBP] No known cover image\n", millis()); + return false; + } + + if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || + coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); + File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); + readItemContentsToStream(coverImageItem, coverJpg, 1024); + coverJpg.close(); + + coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); + File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + SD.remove((getCachePath() + "/.cover.jpg").c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); + SD.remove(getCoverBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis()); + } + + return false; +} std::string normalisePath(const std::string& path) { std::vector components; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 31153035..381379c5 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -48,7 +48,8 @@ class Epub { const std::string& getCachePath() const; const std::string& getPath() const; const std::string& getTitle() const; - const std::string& getCoverImageItem() const; + std::string getCoverBmpPath() const; + bool generateCoverBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 0e0b0d61..c9ad6f85 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -128,7 +128,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { int bitShift = 6; // Helper lambda to pack 2bpp color into the output stream - auto packPixel = [&](uint8_t lum) { + auto packPixel = [&](const uint8_t lum) { uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -140,38 +140,49 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { } }; + uint8_t lum; + switch (bpp) { - case 8: { + case 32: { + const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - packPixel(paletteLum[rowBuffer[x]]); + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 4; } break; } case 24: { const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; packPixel(lum); p += 3; } break; } + case 8: { + for (int x = 0; x < width; x++) { + packPixel(paletteLum[rowBuffer[x]]); + } + break; + } + case 2: { + for (int x = 0; x < width; x++) { + lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + packPixel(lum); + } + break; + } case 1: { for (int x = 0; x < width; x++) { - uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; packPixel(lum); } break; } - case 32: { - const uint8_t* p = rowBuffer; - for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; - packPixel(lum); - p += 4; - } - break; - } + default: + return BmpReaderError::UnsupportedBpp; } // Flush remaining bits if width is not a multiple of 4 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 89590721..fe5e2a07 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -23,7 +23,7 @@ bool CrossPointSettings::saveToFile() const { std::ofstream outputFile(SETTINGS_FILE); serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); - serialization::writePod(outputFile, whiteSleepScreen); + serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); outputFile.close(); @@ -54,7 +54,7 @@ bool CrossPointSettings::loadFromFile() { // load settings that exist uint8_t settingsRead = 0; do { - serialization::readPod(inputFile, whiteSleepScreen); + serialization::readPod(inputFile, sleepScreen); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, extraParagraphSpacing); if (++settingsRead >= fileSettingsCount) break; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d6ad7667..14c33322 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -15,8 +15,11 @@ class CrossPointSettings { CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete; + // Should match with SettingsActivity text + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + // Sleep screen settings - uint8_t whiteSleepScreen = 0; + uint8_t sleepScreen = DARK; // Text rendering settings uint8_t extraParagraphSpacing = 1; // Duration of the power button press diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 900a0dbb..4200c4e9 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,16 +1,45 @@ #include "SleepActivity.h" +#include #include #include #include #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "config.h" #include "images/CrossLarge.h" void SleepActivity::onEnter() { renderPopup("Entering Sleep..."); + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { + return renderCustomSleepScreen(); + } + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) { + return renderCoverSleepScreen(); + } + + renderDefaultSleepScreen(); +} + +void SleepActivity::renderPopup(const char* message) const { + const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); + constexpr int margin = 20; + const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; + constexpr int y = 117; + const int w = textWidth + margin * 2; + const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; + // renderer.clearScreen(); + renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); + renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); + renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.displayBuffer(); +} + +void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = SD.open("/sleep"); if (dir && dir.isDirectory()) { @@ -28,31 +57,31 @@ void SleepActivity::onEnter() { } if (filename.substr(filename.length() - 4) != ".bmp") { - Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), file.name()); file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), file.name()); file.close(); continue; } files.emplace_back(filename); file.close(); } - int numFiles = files.size(); + const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - int randomFileIndex = random(numFiles); - auto filename = "/sleep/" + files[randomFileIndex]; + const auto randomFileIndex = random(numFiles); + const auto filename = "/sleep/" + files[randomFileIndex]; auto file = SD.open(filename.c_str()); if (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); Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - renderCustomSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap); dir.close(); return; } @@ -67,8 +96,8 @@ void SleepActivity::onEnter() { if (file) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis()); - renderCustomSleepScreen(bitmap); + Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); + renderBitmapSleepScreen(bitmap); return; } } @@ -76,41 +105,27 @@ void SleepActivity::onEnter() { renderDefaultSleepScreen(); } -void SleepActivity::renderPopup(const char* message) const { - const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); - constexpr int margin = 20; - const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 117; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; - // renderer.clearScreen(); - renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); - renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); - renderer.displayBuffer(); -} - void SleepActivity::renderDefaultSleepScreen() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); - // Apply white screen if enabled in settings - if (!SETTINGS.whiteSleepScreen) { + // Make sleep screen dark unless light is selected in settings + if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) { renderer.invertScreen(); } renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } -void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { // image will scale, make sure placement is right @@ -153,3 +168,26 @@ void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { renderer.setRenderMode(GfxRenderer::BW); } } + +void SleepActivity::renderCoverSleepScreen() const { + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastEpub.load()) { + Serial.println("[SLP] Failed to load last epub"); + return renderDefaultSleepScreen(); + } + if (!lastEpub.generateCoverBmp()) { + Serial.println("[SLP] Failed to generate cover bmp"); + return renderDefaultSleepScreen(); + } + + auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); + if (file) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + return; + } + } + + renderDefaultSleepScreen(); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index defc1d5e..21121994 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -9,7 +9,9 @@ class SleepActivity final : public Activity { void onEnter() override; private: - void renderDefaultSleepScreen() const; - void renderCustomSleepScreen(const Bitmap& bitmap) const; void renderPopup(const char* message) const; + void renderDefaultSleepScreen() const; + void renderCustomSleepScreen() const; + void renderCoverSleepScreen() const; + void renderBitmapSleepScreen(const Bitmap& bitmap) const; }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b3acf3ff..29f68762 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,11 +6,14 @@ #include "config.h" // Define the static settings list - -const SettingInfo SettingsActivity::settingsList[settingsCount] = { - {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, - {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}}; +namespace { +constexpr int settingsCount = 3; +const SettingInfo settingsList[settingsCount] = { + // Should match with SLEEP_SCREEN_MODE + {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; +} // namespace void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); @@ -81,15 +84,18 @@ void SettingsActivity::toggleCurrentSetting() { const auto& setting = settingsList[selectedSettingIndex]; - // Only toggle if it's a toggle type and has a value pointer - if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) { + if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { + // Toggle the boolean value using the member pointer + const bool currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentValue; + } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { + const uint8_t currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else { + // Only toggle if it's a toggle type and has a value pointer return; } - // Toggle the boolean value using the member pointer - bool currentValue = SETTINGS.*(setting.valuePtr); - SETTINGS.*(setting.valuePtr) = !currentValue; - // Save settings when they change SETTINGS.saveToFile(); } @@ -129,8 +135,13 @@ void SettingsActivity::render() const { // Draw value based on setting type if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - bool value = SETTINGS.*(settingsList[i].valuePtr); + const bool value = SETTINGS.*(settingsList[i].valuePtr); renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); + auto valueText = settingsList[i].enumValues[value]; + const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str()); + renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str()); } } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 7843a5cf..333f467c 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -12,13 +12,14 @@ class CrossPointSettings; -enum class SettingType { TOGGLE }; +enum class SettingType { TOGGLE, ENUM }; // Structure to hold setting information struct SettingInfo { const char* name; // Display name of the setting SettingType type; // Type of setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE) + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM) + std::vector enumValues; }; class SettingsActivity final : public Activity { @@ -28,10 +29,6 @@ class SettingsActivity final : public Activity { int selectedSettingIndex = 0; // Currently selected setting const std::function onGoHome; - // Static settings list - static constexpr int settingsCount = 3; // Number of settings - static const SettingInfo settingsList[settingsCount]; - static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; From 424104f8ff5f6c00fa7838079a615f346c1f8498 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 19:01:00 +1100 Subject: [PATCH 15/33] Fix incorrect justification of last line in paragraph (#90) ## Summary * Fix incorrect justification of last line in paragraph * `words` is changing size due to the slice, so `isLastLine` would rarely be right, either removing justification mid-paragraph, or including it in the last line. ## Additional Context * Introduced in #73 --- lib/Epub/Epub/ParsedText.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index eff3fd63..d73f80a5 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -144,7 +144,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const int spareSpace = pageWidth - lineWordWidthSum; int spacing = spaceWidth; - const bool isLastLine = lineBreak == words.size(); + const bool isLastLine = breakIndex == lineBreakIndices.size() - 1; if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { spacing = spareSpace / (lineWordCount - 1); From febf79a98aafacae82909339b6ec79e53fc00eaa Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 21 Dec 2025 13:01:11 +0500 Subject: [PATCH 16/33] Fix: restores cyrillic glyphs to Pixel Arial font (#70) ## Summary * adds cyrillic glyphs to pixel arial font, used as Small font in UI ## Additional Context * with recent changes pixel arial font lost cyrillic glyphs --- lib/EpdFont/builtinFonts/pixelarial14.h | 979 ++++++++++++++++++++++-- src/config.h | 2 +- 2 files changed, 919 insertions(+), 62 deletions(-) diff --git a/lib/EpdFont/builtinFonts/pixelarial14.h b/lib/EpdFont/builtinFonts/pixelarial14.h index c8e6a0e7..70c9e5cb 100644 --- a/lib/EpdFont/builtinFonts/pixelarial14.h +++ b/lib/EpdFont/builtinFonts/pixelarial14.h @@ -7,71 +7,452 @@ #pragma once #include "EpdFontData.h" -static const uint8_t pixelarial14Bitmaps[1145] = { - 0xFF, 0xFF, 0xFA, 0xC0, 0xFF, 0xFF, 0xFB, 0x18, 0x63, 0x0C, 0x63, 0x98, 0xCF, 0xFF, 0xFF, 0x9C, 0xE3, 0x18, 0xFF, - 0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xF7, 0xBF, 0x1F, 0x1F, 0x9B, 0x37, 0xEF, 0xFB, 0xC3, 0x00, 0x70, - 0x67, 0xCE, 0x36, 0x61, 0xB3, 0x0D, 0xB0, 0x7D, 0x81, 0xDD, 0xC0, 0xDE, 0x07, 0x98, 0xEC, 0xC6, 0x66, 0x33, 0xF3, - 0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1E, 0x03, 0xE0, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xE7, - 0xE6, 0xFF, 0xE0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36, - 0x6E, 0xC0, 0x6F, 0xF6, 0xFB, 0x08, 0x0C, 0x06, 0x03, 0x0F, 0xFF, 0xFC, 0x60, 0x30, 0x18, 0x04, 0x00, 0xBF, 0xC0, - 0x03, 0xEF, 0x80, 0xB0, 0x18, 0x61, 0x8C, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, +static const uint8_t pixelarial14Bitmaps[8296] = { + 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x1C, 0x63, 0x8C, 0xF7, 0x98, 0xCF, 0xFF, 0xFF, 0xDC, 0xE7, 0xFE, 0xFF, + 0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xFF, 0xBF, 0x1F, 0x9F, 0x9B, 0x37, 0xEF, 0xFB, 0xE3, 0x00, 0x70, + 0x67, 0xCF, 0x37, 0x61, 0xBB, 0x0D, 0xF0, 0x7D, 0x81, 0xDD, 0xC0, 0xDF, 0x07, 0x98, 0xFC, 0xC7, 0x66, 0x7B, 0xF3, + 0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1F, 0x07, 0xE4, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xF7, + 0xE6, 0xFF, 0xF0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36, + 0x6E, 0xC0, 0x6F, 0xF6, 0xFF, 0x08, 0x0E, 0x07, 0x03, 0x8F, 0xFF, 0xFC, 0x70, 0x38, 0x1C, 0x0E, 0x00, 0xFF, 0xC0, + 0xFB, 0xFF, 0x80, 0xF0, 0x1C, 0x73, 0xCC, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x37, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x30, 0x7E, 0xFF, 0xC3, 0xC3, - 0x03, 0x03, 0x07, 0x06, 0x18, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3, - 0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0x63, 0x31, 0x99, 0xCC, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F, + 0x03, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3, + 0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0xE3, 0x31, 0x99, 0xCD, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F, 0x7F, 0xE0, 0xC0, 0xFE, 0xFF, 0xC3, 0x03, 0x03, 0xC3, 0xC7, 0xFE, 0x7E, 0x7E, 0xFF, 0xC3, 0xC0, 0xC0, 0xFE, 0xFF, - 0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x0E, 0x18, 0x18, 0x18, 0x38, 0x30, 0x30, 0x30, + 0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x1E, 0x1C, 0x1C, 0x1C, 0x38, 0x30, 0x30, 0x30, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC7, - 0xFF, 0x7B, 0x03, 0x03, 0xC7, 0xFE, 0x78, 0xB0, 0x00, 0x2C, 0xB0, 0x00, 0x2F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0, - 0xC0, 0x70, 0x3E, 0x0F, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x80, 0xC0, 0x70, 0x7E, 0x0F, 0x03, 0x1E, 0x7C, - 0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x0E, 0x18, 0x30, 0x30, 0x30, 0x10, 0x30, 0x3F, 0x87, 0xFE, 0xEF, - 0x7D, 0xF3, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x00, 0xF0, 0x1B, 0x01, - 0xB0, 0x1B, 0x03, 0xB8, 0x31, 0x83, 0x18, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78, - 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0x60, 0x3C, 0x01, 0x80, - 0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0x3F, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0x60, 0x3C, 0x07, 0x80, 0xF0, - 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0x7F, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0, + 0xFF, 0x7F, 0x03, 0x03, 0xC7, 0xFE, 0x7C, 0xF0, 0x00, 0x3C, 0xF0, 0x00, 0x3F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0, + 0xF0, 0x70, 0x7E, 0x1F, 0x03, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xC0, 0xC0, 0x70, 0x7E, 0x1F, 0x0F, 0x1E, 0x7E, + 0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x1E, 0x1C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3F, 0x87, 0xFE, 0xFF, + 0x7D, 0xFB, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x01, 0xF0, 0x1F, 0x01, + 0xF0, 0x1F, 0x03, 0xB8, 0x31, 0x87, 0xFC, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78, + 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0xE0, 0x3C, 0x01, 0x80, + 0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0xBF, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0xE0, 0x3C, 0x07, 0x80, 0xF0, + 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0xFF, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFB, 0xFD, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x0C, 0x00, 0xC1, 0xFC, 0x1F, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x18, 0xFF, 0xFF, - 0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0x80, 0xC0, 0x78, 0x3B, 0x0E, 0x61, - 0x8C, 0x61, 0x9C, 0x3F, 0x87, 0xB0, 0xE3, 0x18, 0x63, 0x0E, 0x60, 0xEC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, + 0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0xC0, 0xC0, 0x78, 0x3F, 0x0E, 0x61, + 0x8C, 0x61, 0xBC, 0x3F, 0x87, 0xB8, 0xE3, 0x1C, 0x63, 0x0E, 0x60, 0xFC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xC0, 0x3E, 0x07, 0xE0, 0x7E, 0x07, 0xF1, 0xBF, 0x1B, 0xFB, - 0xBD, 0xB3, 0xDB, 0x3D, 0xB3, 0xCF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xCF, 0x67, 0xBB, + 0xBD, 0xF3, 0xDF, 0x3D, 0xF3, 0xDF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xEF, 0x77, 0xBB, 0xC7, 0xE3, 0xF1, 0xF8, 0x7C, 0x18, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xFF, 0x7F, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0xFF, 0xFE, 0xC0, 0x60, 0x30, - 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x36, 0xE3, + 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x37, 0xE3, 0xE7, 0xFF, 0x3F, 0x70, 0xFF, 0x9F, 0xFF, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xFF, 0xFF, 0xFC, 0xC6, 0x18, 0xE3, 0x0E, - 0x60, 0xEC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xC1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF, - 0xFF, 0xC6, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, - 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1C, - 0x31, 0x83, 0x18, 0x1B, 0x01, 0xB0, 0x0F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C, - 0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xDC, 0x6C, 0x78, 0x36, 0x3C, 0x1B, 0x1E, 0x0F, 0x8F, 0x03, 0x03, 0x01, 0x81, - 0x80, 0xC0, 0x7C, 0x39, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF0, 0x33, 0x0E, 0x31, 0x86, 0x70, 0xEC, 0x06, - 0xC0, 0x3E, 0x07, 0x70, 0xE3, 0x18, 0x31, 0x83, 0xB8, 0x0F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, - 0x00, 0xFF, 0xFF, 0xF8, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x03, 0x80, 0xC0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xEF, 0xFE, - 0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x86, 0x18, 0xFD, 0xB6, - 0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xE3, 0xC7, 0x9D, 0xF1, 0x80, 0xFF, 0xDF, 0xFC, 0xCE, 0x73, 0x00, 0x7C, - 0x7E, 0xC3, 0x83, 0x3F, 0x3F, 0x63, 0xC3, 0xC7, 0xFF, 0x7B, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xF3, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xE0, 0x03, 0x03, 0x7B, 0x7B, 0xC7, - 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x7C, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E, - 0x39, 0xEF, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, - 0xFF, 0x7B, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, - 0xFB, 0xFF, 0xFF, 0xC0, 0x33, 0x13, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xCE, - 0xF8, 0xF0, 0xF8, 0xCE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0x98, 0xCF, 0x9E, 0xE7, 0x3E, 0x73, 0xC6, 0x3C, - 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, - 0xC3, 0x7C, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, - 0x03, 0x03, 0x03, 0x03, 0x9B, 0xEE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xF3, 0x1E, 0x3F, 0x8F, 0x81, - 0x83, 0xC7, 0xFD, 0xE0, 0x61, 0x8F, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x78, 0x83, 0xC3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x3E, 0x0C, 0x06, 0x00, - 0x84, 0x3C, 0x63, 0xC6, 0x3E, 0xF7, 0x7B, 0x67, 0xB6, 0x7B, 0x67, 0xB6, 0x7B, 0xC3, 0x18, 0x31, 0x80, 0x83, 0xC3, - 0x66, 0x66, 0x7E, 0x38, 0x38, 0x7E, 0x66, 0xE7, 0xC3, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x36, - 0x1F, 0x06, 0x03, 0x01, 0x83, 0xC1, 0xC0, 0xFF, 0xFF, 0x06, 0x0E, 0x18, 0x18, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37, - 0x66, 0x66, 0x66, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x66, 0x37, 0x66, 0x66, - 0x6E, 0xC0, 0xC3, 0x99, 0xFF, 0xF9, 0xB8, 0x30, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x00, 0x7B, 0xEF, 0x3C, 0xF2, 0xC0, - 0x79, 0xE7, 0x9E, 0xF2, 0xC0, + 0x60, 0xFC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xE1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF, + 0xFF, 0xC7, 0x03, 0x81, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, + 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1E, + 0x31, 0x83, 0xB8, 0x1F, 0x01, 0xF0, 0x1F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C, + 0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xFC, 0x6C, 0x7C, 0x36, 0x3E, 0x1B, 0x1F, 0x0F, 0x8F, 0x03, 0x83, 0x01, 0xC1, + 0x80, 0xC0, 0x7C, 0x3D, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF8, 0x33, 0x0E, 0x71, 0x86, 0x70, 0xFC, 0x06, + 0xC0, 0x3E, 0x07, 0x71, 0xE3, 0x18, 0x31, 0x83, 0xF8, 0x1F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, + 0x00, 0xFF, 0xFF, 0xFC, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x07, 0x80, 0xE0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xFF, 0xFE, + 0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x87, 0x1C, 0xFD, 0xB6, + 0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xF3, 0xE7, 0xDD, 0xF1, 0x80, 0xFF, 0xFF, 0xFC, 0xCE, 0x73, 0x00, 0x7E, + 0x7E, 0xC3, 0xC3, 0x3F, 0x7F, 0x63, 0xE3, 0xC7, 0xFF, 0x7F, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xFB, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xF0, 0x03, 0x03, 0x7B, 0x7F, 0xC7, + 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0x7E, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E, + 0x3D, 0xEF, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, + 0xFF, 0x7F, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xFF, 0xFF, 0xFF, 0xC0, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xDE, + 0xFC, 0xF8, 0xFC, 0xEE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0xF9, 0xEF, 0xDE, 0xE7, 0x3E, 0x73, 0xC6, 0x3C, + 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xC3, 0x7E, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, + 0x03, 0x03, 0x03, 0x03, 0xFB, 0xFE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xFB, 0x1E, 0x3F, 0x8F, 0x81, + 0x83, 0xC7, 0xFD, 0xF0, 0x61, 0x8F, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x7C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x3E, 0x0E, 0x07, 0x00, + 0xC6, 0x3C, 0x63, 0xC6, 0x3F, 0xF7, 0x7F, 0x67, 0xF6, 0x7F, 0x67, 0xF6, 0x7B, 0xE3, 0x18, 0x31, 0x80, 0xC3, 0xC3, + 0x66, 0x66, 0x7E, 0x3C, 0x3C, 0x7E, 0x66, 0xE7, 0xC3, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x36, + 0x1F, 0x07, 0x03, 0x81, 0xC3, 0xE1, 0xC0, 0xFF, 0xFF, 0x06, 0x1E, 0x1C, 0x1C, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37, + 0x66, 0x66, 0x6E, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x67, 0x37, 0x66, 0x66, + 0x6E, 0xC0, 0xC3, 0x9B, 0xFF, 0xF9, 0xB8, 0x30, 0xFC, 0x0D, 0xBF, 0xFF, 0xF0, 0x1C, 0x1C, 0x3E, 0x7E, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0x7A, 0x3E, 0x1C, 0x1C, 0x3F, 0x3B, 0x70, 0x70, 0x70, 0x70, 0xFE, 0x70, 0x70, 0x70, 0x60, 0x7F, + 0x61, 0xBF, 0xDF, 0xCC, 0x66, 0x33, 0x19, 0xFC, 0xFF, 0x61, 0x80, 0xE1, 0xD8, 0xE7, 0x30, 0xDC, 0x3E, 0x07, 0x87, + 0xF8, 0x30, 0x0C, 0x1F, 0xE0, 0xC0, 0x30, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0x3F, 0x70, 0x60, 0x70, 0x7C, 0x6F, + 0xE3, 0xE3, 0x7F, 0x3E, 0x07, 0x07, 0x6E, 0xFE, 0xEF, 0xB0, 0x1F, 0x87, 0x1C, 0x66, 0x6D, 0xFF, 0xD8, 0x3F, 0x83, + 0xD8, 0x3D, 0x83, 0xDF, 0xF6, 0x66, 0x71, 0xE1, 0xF8, 0x7D, 0x37, 0xFB, 0xED, 0xF0, 0x3B, 0x77, 0x7E, 0xEE, 0x7E, + 0x77, 0x3B, 0xFF, 0x80, 0xC0, 0x60, 0x30, 0x18, 0xF8, 0x1F, 0x87, 0x1C, 0x60, 0x6D, 0xF7, 0xDB, 0xBD, 0xBB, 0xDF, + 0x3D, 0xB3, 0xD9, 0xF6, 0x06, 0x71, 0xE1, 0xF8, 0xFC, 0x7B, 0xEC, 0xFE, 0x78, 0x0C, 0x06, 0x03, 0x1F, 0xF0, 0xC0, + 0x60, 0x30, 0x00, 0x00, 0x7F, 0xC0, 0xFB, 0x61, 0x8E, 0x71, 0x8F, 0xC0, 0xF9, 0x61, 0x9E, 0x1F, 0x7F, 0x80, 0x6E, + 0xC0, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0xFF, 0xC0, 0xC0, 0xC0, 0x3F, 0xDF, 0x77, 0xDF, 0xF7, 0xFD, + 0xDF, 0x77, 0xDC, 0x77, 0x0D, 0xC3, 0x70, 0xDC, 0x37, 0x0D, 0xC3, 0x70, 0xDC, 0x77, 0x67, 0xF0, 0x73, 0x8C, 0x63, + 0x18, 0xC0, 0x7C, 0xDF, 0x9F, 0x36, 0xEF, 0x80, 0xEC, 0x6E, 0x77, 0x3F, 0x77, 0x6E, 0xEC, 0x70, 0x70, 0xE0, 0xC0, + 0xC3, 0x81, 0x86, 0x03, 0x18, 0x06, 0x73, 0x8C, 0xCF, 0x03, 0x9E, 0x06, 0x6C, 0x1C, 0xFC, 0x30, 0x30, 0xE0, 0x60, + 0x70, 0x60, 0xE1, 0xC0, 0xC3, 0x01, 0x8E, 0x03, 0x18, 0x06, 0x7F, 0x8C, 0xDB, 0x03, 0x06, 0x0E, 0x1C, 0x18, 0x70, + 0x71, 0xC0, 0xC3, 0xE0, 0xF8, 0x70, 0xB0, 0xC0, 0x63, 0x83, 0xC6, 0x01, 0xDC, 0x1B, 0xB3, 0xBE, 0xEF, 0x01, 0x9E, + 0x07, 0x6C, 0x1C, 0xFC, 0x30, 0x30, 0xE0, 0x60, 0x38, 0x70, 0x00, 0x01, 0x87, 0x0E, 0x38, 0xE1, 0xC3, 0xFB, 0xF0, + 0x1C, 0x01, 0xE0, 0x06, 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, + 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x03, 0x00, 0xF0, 0x0E, 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, + 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x0E, 0x01, 0xF0, 0x1B, 0x00, 0x00, 0x0E, 0x00, 0xE0, + 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x1F, 0x81, 0xF0, 0x00, + 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, + 0x06, 0x1B, 0x81, 0xB8, 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, + 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x0E, 0x01, 0xF0, 0x0F, 0x00, 0xE0, 0x0F, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, + 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x03, 0xFE, 0x03, 0xC0, 0x07, 0xC0, 0x06, 0xC0, 0x0E, 0xC0, + 0x1C, 0xFE, 0x18, 0xC0, 0x38, 0xC0, 0x3F, 0xC0, 0x70, 0xC0, 0xE0, 0xC0, 0xE0, 0xFF, 0x02, 0x03, 0xF8, 0xF3, 0x38, + 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0x0E, 0x01, 0xC0, 0x78, 0x38, 0x1E, + 0x07, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x0E, 0x0F, 0x06, + 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x18, 0x1E, 0x19, 0x80, + 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x7E, 0x3F, 0x00, 0x1F, 0xEE, + 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0xCF, 0x70, 0x77, 0x77, 0x77, 0x77, 0x77, + 0x77, 0x6E, 0xC0, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0x31, 0xE6, 0xC0, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, + 0xE3, 0x8E, 0xEF, 0xB0, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0x7F, 0x87, 0x3C, 0x70, 0xE7, + 0x06, 0x70, 0x7F, 0xC7, 0x70, 0x77, 0x07, 0x70, 0x67, 0x0E, 0x73, 0xC7, 0xF8, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0xE0, + 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x0E, 0x00, 0x78, 0x00, 0xC0, + 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, + 0x83, 0xF8, 0x03, 0x80, 0x3C, 0x01, 0xC0, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, + 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x07, 0x00, 0x7C, 0x03, 0x60, 0x00, 0x01, 0xFC, 0x1E, 0xF1, + 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x0F, 0xC0, 0x7C, + 0x00, 0x00, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, + 0x38, 0xF7, 0x83, 0xF8, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, + 0x01, 0xD8, 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x42, 0xEE, 0x7E, 0x3C, 0x7E, 0xEE, 0x42, 0x1F, 0xF1, + 0xEF, 0x9C, 0x3C, 0xE3, 0xE6, 0x3B, 0xB1, 0x9D, 0x9C, 0xED, 0xC7, 0x7C, 0x33, 0xE3, 0x8F, 0x78, 0xFF, 0x80, 0x18, + 0x07, 0x00, 0xC0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, 0xFC, + 0x06, 0x03, 0x80, 0xC0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, + 0xFC, 0x0C, 0x07, 0x83, 0x20, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, + 0xF8, 0xFC, 0x3F, 0x0F, 0xC0, 0x03, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, + 0xE3, 0xF0, 0x06, 0x01, 0xC0, 0x30, 0x00, 0x0E, 0x1D, 0xC3, 0x9C, 0xE1, 0xDC, 0x3F, 0x03, 0xC0, 0x78, 0x06, 0x00, + 0xC0, 0x18, 0x03, 0x00, 0x60, 0xE0, 0x70, 0x3F, 0x9D, 0xEE, 0x3F, 0x0F, 0x87, 0xC7, 0xEF, 0x7F, 0x38, 0x1C, 0x00, + 0x7E, 0x7B, 0xB9, 0xD8, 0xEC, 0xE6, 0xE3, 0x71, 0x9E, 0xC7, 0xE0, 0xF0, 0x7B, 0x7D, 0xF0, 0x38, 0x3C, 0x0C, 0x00, + 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x0E, 0x1E, 0x18, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, + 0xE3, 0x73, 0x7F, 0x1C, 0x3E, 0x36, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x3F, 0x7E, 0x00, + 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x36, 0x36, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, + 0xE3, 0x73, 0x7F, 0x1C, 0x36, 0x36, 0x1C, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x7F, 0xF8, + 0xBF, 0xF0, 0x79, 0xC0, 0xC3, 0x7F, 0xFF, 0xDC, 0x0E, 0x38, 0x1D, 0xF6, 0x7F, 0xF8, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0x0C, 0x0E, 0x1C, 0x18, 0x0E, 0x03, 0x00, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, + 0x0F, 0x63, 0xF0, 0x06, 0x07, 0x03, 0x00, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x0C, + 0x0F, 0x0C, 0x80, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x3F, 0x1F, 0x80, 0x07, 0xE7, + 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0xCE, 0x60, 0x66, 0x66, 0x66, 0x66, 0x60, 0x6E, 0xC0, 0xCC, + 0xCC, 0xCC, 0xCC, 0xC0, 0x31, 0xE6, 0xC0, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xFF, 0xF0, 0x0C, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x0F, 0xC3, 0xF0, 0xF8, 0x0E, 0x01, 0x8F, 0xE7, 0xF9, 0x86, 0xE1, 0x98, 0x66, 0x39, 0xFC, + 0x3F, 0x00, 0x7E, 0x7E, 0x00, 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x18, 0x07, 0x00, 0xC0, + 0x00, 0x3F, 0x1F, 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x06, 0x03, 0x80, 0xC0, 0x00, 0x3F, + 0x1F, 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x0C, 0x07, 0x83, 0x30, 0x00, 0x3F, 0x1F, 0xE6, + 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0x3F, 0x1F, 0xE6, 0x19, 0x86, + 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, 0x98, + 0x67, 0xF8, 0xFC, 0x1C, 0x0E, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x38, 0x1C, 0x00, 0x3F, 0x9F, 0xE6, 0x79, 0xBE, + 0xED, 0xDF, 0x67, 0x99, 0xFE, 0x7F, 0x00, 0x30, 0x38, 0x18, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, + 0x7F, 0x0C, 0x1C, 0x18, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x18, 0x3C, 0x6C, 0x00, 0xC7, + 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x7E, 0x7E, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, + 0x7F, 0x0E, 0x0F, 0x06, 0x00, 0x0E, 0x37, 0x19, 0x9C, 0xCE, 0x76, 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, + 0xC0, 0x60, 0x30, 0x18, 0x0F, 0xE7, 0x7B, 0x0D, 0x86, 0xC3, 0xE1, 0xB0, 0xDF, 0xEF, 0xE6, 0x03, 0x01, 0x80, 0x36, + 0x1B, 0x00, 0x1C, 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x1F, 0x00, 0x00, + 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, + 0x60, 0x3E, 0x00, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1B, 0x01, 0xF0, 0x00, 0x00, 0xE0, + 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, 0x3E, + 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, + 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x00, 0xE0, 0x0C, 0x00, 0xF0, 0x7E, 0x2F, 0x03, 0x7F, 0x77, + 0xE3, 0xE3, 0x73, 0x7F, 0x06, 0x0E, 0x0F, 0x03, 0x00, 0xE0, 0x18, 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, + 0x0C, 0x01, 0x80, 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x06, 0x0E, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0x06, 0x01, 0xE0, 0x36, 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x80, + 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x0C, 0x1E, 0x32, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, + 0x3F, 0x07, 0x00, 0xE0, 0x08, 0x0F, 0xE3, 0xCC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x01, 0x80, 0x38, 0x07, 0x00, + 0x79, 0x87, 0xF0, 0x0C, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x0D, 0x81, 0xE0, 0x18, + 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x80, 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x32, + 0x1E, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x36, 0x07, 0xC0, 0x70, 0x00, 0x0F, 0xF1, + 0xCF, 0x38, 0x77, 0x0E, 0xE0, 0xDC, 0x1F, 0x83, 0xF0, 0x6E, 0x1D, 0xC3, 0xB9, 0xE7, 0xF8, 0x01, 0xF0, 0x1F, 0x01, + 0xF0, 0x1E, 0x3F, 0x87, 0xF8, 0x61, 0x86, 0x18, 0xE1, 0x86, 0x18, 0x61, 0x87, 0xB8, 0x3F, 0x80, 0x7F, 0x87, 0x3C, + 0x70, 0xE7, 0x06, 0x70, 0x7F, 0xC7, 0x70, 0x77, 0x07, 0x70, 0x67, 0x0E, 0x73, 0xC7, 0xF8, 0x01, 0x80, 0x60, 0xFC, + 0x06, 0x3F, 0x9F, 0xE6, 0x19, 0x86, 0xE1, 0x98, 0x66, 0x19, 0xEE, 0x3F, 0x80, 0x7E, 0x00, 0x00, 0x1F, 0xEE, 0x07, + 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x3F, 0x00, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, + 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0x7E, 0x1E, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, + 0x0E, 0x07, 0x03, 0xFE, 0x36, 0x0F, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0x18, + 0x0C, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x0C, 0x06, 0x00, + 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xF3, 0x81, + 0xC0, 0xE0, 0x70, 0x38, 0x1F, 0xF0, 0x70, 0x30, 0x1E, 0x3F, 0x3F, 0x98, 0xEC, 0x7F, 0xFB, 0x01, 0x80, 0xF6, 0x3F, + 0x03, 0x83, 0x80, 0xE0, 0x64, 0x1E, 0x06, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, + 0x03, 0x81, 0xFF, 0x32, 0x0F, 0x03, 0x00, 0x00, 0x01, 0xF9, 0xFC, 0xC7, 0x63, 0xFF, 0xD8, 0x0C, 0x07, 0xB1, 0xF8, + 0x07, 0x01, 0xF0, 0x36, 0x00, 0x01, 0xFC, 0x7B, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x83, 0xB0, 0x77, 0x0E, 0xE1, + 0xCF, 0x38, 0xFF, 0x0C, 0x0F, 0x06, 0xC0, 0x03, 0xFB, 0xDD, 0x8E, 0xC7, 0xE3, 0xF1, 0xD8, 0xEF, 0xF3, 0xF8, 0x1D, + 0xDC, 0xFE, 0x0D, 0x81, 0xF0, 0x00, 0x0F, 0xE3, 0xDC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x1D, 0x83, 0xB8, 0x77, + 0x0E, 0x79, 0xC7, 0xF8, 0x1F, 0x0F, 0x00, 0x07, 0xF7, 0xBB, 0x1D, 0x8F, 0xC7, 0xE3, 0xB1, 0xDF, 0xE7, 0xF0, 0x3B, + 0xB9, 0xFC, 0x07, 0x00, 0xE0, 0x00, 0x0F, 0xE3, 0xDC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x1D, 0x83, 0xB8, 0x77, + 0x0E, 0x79, 0xC7, 0xF8, 0x0C, 0x06, 0x00, 0x07, 0xF7, 0xBB, 0x1D, 0x8F, 0xC7, 0xE3, 0xB1, 0xDF, 0xE7, 0xF0, 0x3B, + 0xB9, 0xFC, 0x1F, 0xC7, 0xB9, 0xC0, 0x38, 0x06, 0x00, 0xC0, 0x18, 0x3B, 0x07, 0x70, 0xEE, 0x1C, 0xF3, 0x8F, 0xF0, + 0x00, 0x0E, 0x03, 0x80, 0x06, 0x07, 0x03, 0x00, 0x03, 0xFB, 0xDD, 0x8E, 0xC7, 0xE3, 0xF1, 0xD8, 0xEF, 0xF3, 0xF8, + 0x1D, 0xDC, 0xFE, 0x0C, 0x07, 0x83, 0x30, 0x00, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xFF, 0xFE, 0x1F, 0x87, 0xE1, + 0xF8, 0x7E, 0x1F, 0x87, 0x78, 0x3C, 0x00, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xFE, 0x77, 0x31, 0xD8, 0xEC, 0x76, 0x3B, + 0x1D, 0x8E, 0xC7, 0x70, 0x63, 0x83, 0x3F, 0xFE, 0xE0, 0xC7, 0x06, 0x3F, 0xF1, 0xC1, 0x8E, 0x0C, 0x70, 0x63, 0x83, + 0x1C, 0x18, 0xE0, 0xC0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, + 0x7D, 0xF8, 0x00, 0x03, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0xFF, 0xF0, 0x00, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xFC, 0x00, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0xFC, 0x00, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0xDF, 0x80, 0xE7, 0x39, 0xCE, 0x73, 0x9C, 0xE7, 0x39, 0xC0, 0x7D, 0xE0, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xEC, 0xF0, 0x66, 0x00, 0x66, 0x66, + 0x66, 0x66, 0x6E, 0xCF, 0xFC, 0x7F, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xC0, 0xE0, 0x7C, 0x0F, 0x81, 0xF0, 0x3E, + 0x07, 0xC0, 0xF8, 0x1F, 0x03, 0xE0, 0x7C, 0x1F, 0xB7, 0xF7, 0xE0, 0xCF, 0x9C, 0x00, 0x0C, 0xF9, 0xF3, 0xE7, 0xCF, + 0x9F, 0x3E, 0x7C, 0xE1, 0xC3, 0x1E, 0x06, 0x07, 0x83, 0x60, 0x00, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, + 0xE0, 0x60, 0x33, 0xB9, 0xF8, 0x18, 0x78, 0xD8, 0x01, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x0E, 0x38, + 0xE3, 0xB9, 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, 0x87, 0x00, 0x07, 0x03, 0xC0, + 0xC0, 0xC0, 0xC0, 0xC0, 0xCE, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, 0xDC, 0xCE, 0xC7, 0x00, 0x38, 0x78, 0xC7, 0xCE, 0xDC, + 0xF8, 0xF8, 0xFC, 0xDE, 0xCE, 0xC7, 0x30, 0xF0, 0xE0, 0x00, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xFF, 0xEE, 0x0C, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xEF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xFF, 0x00, 0x18, 0x38, 0x63, 0x18, 0xC6, 0x31, 0x8C, 0x63, 0x18, 0xE7, 0x81, 0xDC, 0xFC, 0xF8, + 0xF8, 0xF8, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xFF, 0xDE, 0xF7, 0xBC, 0x63, 0x18, 0xC6, 0x31, 0xCF, 0x00, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xEE, 0xEE, 0xE0, 0xE0, 0xE0, 0xE0, 0xFF, 0xC3, 0x0C, 0x30, 0xC3, 0x0D, 0xF7, 0xC3, + 0x0C, 0x38, 0xF0, 0x38, 0x0E, 0x03, 0x80, 0xE0, 0x3E, 0x0F, 0x07, 0x81, 0xE0, 0x38, 0x0E, 0x03, 0x80, 0xFF, 0x73, + 0x9C, 0xE7, 0xBF, 0xDE, 0x73, 0x9C, 0xE3, 0x80, 0x06, 0x03, 0x80, 0xC0, 0x00, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, + 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x0C, 0x1C, 0x18, 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x00, + 0x03, 0x01, 0xC0, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x00, 0x18, 0x38, 0x1B, 0x07, 0x80, 0xC0, + 0x00, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x66, 0x3C, 0x18, + 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE0, 0x70, 0x30, 0x18, 0x00, 0x03, 0xF9, 0xDC, 0xC7, + 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x3B, 0x1C, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, + 0x7E, 0x1F, 0x83, 0x00, 0xC0, 0x70, 0x3C, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x07, 0x07, 0x0E, + 0x0F, 0x80, 0x00, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xE0, + 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x00, 0x00, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, 0x98, 0x67, + 0xF8, 0xFC, 0x0D, 0x80, 0x7C, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, 0x01, 0xD8, + 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x07, 0x80, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, + 0x98, 0x67, 0xF8, 0xFC, 0x07, 0xC0, 0x7E, 0x03, 0x60, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, + 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x0D, 0x87, 0xE1, 0xB0, 0x00, 0x3F, 0x1F, 0xE6, + 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x1F, 0xFF, 0x1F, 0x70, 0x1C, 0x38, 0x0E, 0x1C, 0x06, 0x0E, + 0x03, 0x07, 0xF9, 0x83, 0x80, 0xC1, 0xC0, 0x70, 0xE0, 0x38, 0x70, 0x0F, 0xB8, 0x03, 0xFF, 0xF0, 0x3F, 0x7C, 0x7F, + 0xEE, 0x61, 0xC6, 0x61, 0xC7, 0xE1, 0xFF, 0x61, 0xC0, 0x61, 0xC0, 0x7F, 0xF6, 0x3F, 0x7E, 0x0C, 0x07, 0x01, 0x80, + 0x00, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, 0xEE, 0x3B, 0x87, 0x18, 0xE3, 0x00, + 0xFF, 0xAC, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, + 0xEE, 0x3B, 0x87, 0x00, 0x07, 0x03, 0x80, 0x7E, 0xE9, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x01, 0x87, 0x00, 0x66, + 0x0F, 0x01, 0x80, 0x00, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, 0xEE, 0x3B, 0x87, + 0xC9, 0xE3, 0x00, 0xFF, 0xAC, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0x06, 0x0F, 0x07, 0x00, 0x03, 0xF3, 0xBB, 0x81, 0xC0, + 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, 0x9F, 0xFE, 0x0C, 0x38, 0x60, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, + 0x7F, 0xE0, 0x0C, 0x0F, 0x0D, 0x80, 0x03, 0xF3, 0xBB, 0x81, 0xC0, 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, 0x9F, + 0xFE, 0x18, 0x79, 0x90, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, 0x7F, 0xE0, 0x3F, 0x3B, 0xB8, 0x1C, 0x07, 0x01, + 0xE0, 0x3C, 0x07, 0x03, 0x81, 0xF9, 0xFF, 0xE1, 0xC0, 0xE0, 0xF0, 0x7E, 0xEF, 0x83, 0x83, 0xC1, 0xC1, 0xB7, 0xFE, + 0x70, 0xE1, 0xC0, 0x36, 0x0F, 0x03, 0x00, 0x03, 0xF3, 0xBB, 0x81, 0xC0, 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, + 0x9F, 0xFE, 0x6C, 0x78, 0x60, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, 0x7F, 0xE0, 0xFF, 0xC3, 0x00, 0xC0, 0x30, + 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x1C, 0x07, 0x03, 0xC0, 0xC3, 0x0C, 0x3F, 0xC3, 0x0C, + 0x30, 0xC3, 0x0E, 0xDF, 0x30, 0xE7, 0x80, 0x36, 0x07, 0x80, 0xC0, 0x00, 0xFF, 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, + 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x1B, 0x6D, 0xB0, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x38, 0x7C, 0xFF, + 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x1F, 0xC0, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0xC3, 0x0C, 0x3F, 0xC3, 0x0C, + 0x3F, 0xC3, 0x0E, 0x1F, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, + 0xE1, 0xF8, 0x67, 0xF8, 0xFC, 0x7E, 0x7E, 0x00, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x3F, + 0x00, 0x00, 0x03, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, 0xE3, 0xF0, 0x7E, + 0x00, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x3E, 0x07, 0x80, 0x03, 0x07, 0xC1, 0xF0, 0x7C, + 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, 0xE3, 0xF0, 0x7C, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xE7, 0xF7, 0x7F, 0x1E, 0x07, 0x81, 0xE0, 0x78, 0x00, 0x30, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, + 0xC1, 0xF8, 0x7E, 0x19, 0xFE, 0x3F, 0x00, 0x3C, 0x3C, 0x3C, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, + 0xF7, 0x7F, 0x0F, 0x87, 0xE1, 0xF0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, + 0x67, 0xF8, 0xFC, 0x1F, 0x3F, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0xC1, 0xF0, 0x7C, + 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, 0xFC, 0x0E, 0x03, 0x00, 0xE0, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x0F, 0x0E, 0x0F, 0x01, 0x80, 0x03, 0xC0, 0x06, 0x40, 0x00, 0x00, 0xE0, 0x07, + 0xE1, 0x86, 0xE3, 0xC6, 0x63, 0xC6, 0x73, 0xCE, 0x77, 0xCE, 0x76, 0xEC, 0x36, 0x6C, 0x3E, 0x7C, 0x3C, 0x7C, 0x3C, + 0x38, 0x1C, 0x38, 0x07, 0x00, 0x7C, 0x03, 0x60, 0x00, 0x0E, 0x71, 0xF3, 0x9D, 0x9C, 0xEC, 0xF6, 0x7D, 0xB3, 0xEF, + 0x8F, 0x78, 0x79, 0xC1, 0x8E, 0x00, 0x0C, 0x03, 0xC0, 0x4C, 0x00, 0x0E, 0x1D, 0xC3, 0x9C, 0xE1, 0xDC, 0x3F, 0x03, + 0xC0, 0x78, 0x06, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x1C, 0x1F, 0x0D, 0x80, 0x0E, 0x37, 0x19, 0x9C, 0xCE, 0x76, + 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, 0x3F, 0x07, 0xE0, 0x00, 0x70, 0xEE, 0x1C, 0xE7, 0x0E, 0xE1, 0xF8, + 0x1E, 0x03, 0xC0, 0x30, 0x06, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x06, 0x03, 0x80, 0xC0, 0x00, 0xFF, 0x80, 0xE0, 0x38, + 0x1C, 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x0E, 0x03, 0xFF, 0x0C, 0x1C, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, + 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x0C, 0x03, 0x00, 0x03, 0xFE, 0x03, 0x80, 0xE0, 0x70, 0x38, 0x1C, 0x07, 0x03, 0x81, + 0xC0, 0x70, 0x38, 0x0F, 0xFC, 0x18, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x33, 0x07, + 0x80, 0xC0, 0x00, 0xFF, 0x80, 0xE0, 0x38, 0x1C, 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x0E, 0x03, 0xFF, 0x66, + 0x3C, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x1D, 0xFE, 0x30, 0xC3, 0x0C, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x00, 0x79, 0xF0, 0x38, 0x1E, 0x07, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, + 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x7E, 0x3F, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, + 0x07, 0x03, 0xFE, 0xFF, 0xC0, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0F, 0xF0, 0x7B, 0xC3, 0x86, 0x1C, 0x38, 0xE1, + 0x87, 0x1C, 0x3B, 0xC0, 0x0C, 0x1C, 0x18, 0x00, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0x1F, 0xC7, 0x99, 0xC0, 0x38, 0x06, 0x00, 0xFE, 0x18, 0x03, 0x00, 0x70, 0x0E, 0x00, 0xF3, 0x0F, 0xE0, 0x3F, + 0x3B, 0xB8, 0x1C, 0x07, 0x01, 0xE0, 0x3C, 0x07, 0x03, 0x81, 0xF9, 0xFF, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xEF, + 0xB0, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x06, 0x06, 0xEE, 0xFC, 0x1F, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, 0x3F, 0xC1, 0xCE, + 0x78, 0x63, 0x86, 0x18, 0xE1, 0xCE, 0x38, 0x67, 0x0E, 0x7B, 0x83, 0xFC, 0x00, 0x00, 0x00, 0xE1, 0xC0, 0xE1, 0xC0, + 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xFF, 0xFE, 0xE1, 0xDF, 0xE1, 0xC7, 0xE1, 0xC3, 0xE1, 0xC7, 0xE1, 0xCF, 0xE1, + 0xFE, 0xFF, 0xC1, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xFE, 0x1F, 0xF1, 0xC7, 0x1C, 0x31, 0xC3, 0x1C, 0x31, 0xC3, + 0x0E, 0x07, 0x81, 0xC0, 0x00, 0xE3, 0xB9, 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, + 0x87, 0x18, 0x07, 0x00, 0xE0, 0x00, 0xE0, 0xF8, 0x7E, 0x3F, 0x9F, 0xE6, 0xFB, 0xBE, 0xCF, 0xE3, 0xF8, 0xFC, 0x3E, + 0x0F, 0x83, 0x3B, 0x07, 0xC0, 0x03, 0x87, 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, + 0x0F, 0x80, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0xFF, 0x0C, 0x03, + 0x00, 0xC0, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, + 0x06, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xFB, 0xBF, 0xC7, 0xE1, 0xF1, 0xF9, 0xFF, 0xE0, 0xFF, 0x3B, 0xEE, 0x3B, + 0x8E, 0xE7, 0xBF, 0xCE, 0x7B, 0x86, 0xE1, 0xB8, 0x6E, 0x7B, 0xFC, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0x1F, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x18, 0xE1, 0x8E, 0x38, 0xE3, 0x8E, + 0x70, 0xEF, 0xFF, 0xC0, 0x3C, 0x03, 0xC0, 0x30, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xF3, 0x81, 0xC0, 0xE0, 0x70, + 0x38, 0x1F, 0xF0, 0xF3, 0x8E, 0xE7, 0x38, 0xEE, 0xE0, 0xFF, 0x80, 0xFF, 0x00, 0xFC, 0x03, 0xFC, 0x0D, 0xDC, 0x3B, + 0x98, 0xE7, 0x39, 0xCE, 0x3F, 0x1C, 0x70, 0x7F, 0x3B, 0x80, 0xE0, 0x70, 0xF1, 0xF8, 0x3E, 0x07, 0x01, 0x81, 0xF9, + 0xFF, 0xE0, 0xE0, 0xF8, 0x7E, 0x3F, 0x9F, 0xE6, 0xFB, 0xBE, 0xCF, 0xE3, 0xF8, 0xFC, 0x3E, 0x0F, 0x83, 0x3F, 0x07, + 0xC0, 0x03, 0x83, 0xE1, 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, 0x0C, 0xE3, 0xB9, + 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, 0x87, 0x1F, 0xE3, 0x9C, 0x73, 0x8E, 0x71, + 0xCE, 0x39, 0xC7, 0x38, 0xC7, 0x18, 0xE7, 0x1D, 0xC3, 0xF0, 0x70, 0x60, 0x37, 0x83, 0xBC, 0x1D, 0xF1, 0xFD, 0x8F, + 0xE4, 0xDF, 0x36, 0xF9, 0xA7, 0xC7, 0x3E, 0x38, 0xF0, 0x07, 0x80, 0x30, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xFF, + 0xFE, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0x1F, 0xC1, 0xEF, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xB0, 0x1D, 0x80, + 0xEC, 0x07, 0x70, 0x33, 0x83, 0x8F, 0x78, 0x3F, 0x80, 0xFF, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, + 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xFE, 0x77, 0xB8, 0xFC, 0x3E, 0x1F, 0x1F, 0xBD, 0xFC, 0xE0, 0x70, 0x38, 0x1C, 0x00, + 0x02, 0x03, 0xF8, 0xF3, 0x38, 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0xFF, + 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0xE1, 0xF8, 0x77, 0x1D, 0xC6, + 0x3B, 0x8E, 0xC1, 0xF0, 0x7C, 0x0E, 0x03, 0x85, 0xC3, 0xE0, 0x03, 0x80, 0x7F, 0x83, 0xBF, 0x9C, 0xE6, 0x63, 0x9D, + 0x8E, 0x76, 0x38, 0xD8, 0xE7, 0x63, 0x9D, 0xCE, 0x63, 0xBF, 0x87, 0xFC, 0x03, 0x80, 0xE1, 0xCE, 0x38, 0xEE, 0x1F, + 0x81, 0xF0, 0x1C, 0x07, 0x80, 0xF8, 0x3B, 0x8E, 0x71, 0xC7, 0x70, 0x70, 0xE1, 0xDC, 0x3B, 0x87, 0x70, 0xEE, 0x1D, + 0xC3, 0xB8, 0x77, 0x0E, 0xE1, 0xDC, 0x3B, 0x87, 0x7F, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0x61, 0xD8, 0x76, 0x1D, 0x87, + 0x61, 0xD8, 0x77, 0xBC, 0xFF, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0xE3, 0x8F, 0xC7, 0x1F, 0x8E, 0x3F, 0x1C, 0x7E, 0x38, + 0xFC, 0x71, 0xF8, 0xE3, 0xF1, 0xC7, 0xE3, 0x8F, 0xC7, 0x1F, 0x8E, 0x3F, 0xFF, 0xF0, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, + 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xFF, 0xFF, + 0x00, 0x03, 0x00, 0x03, 0x00, 0x03, 0xF8, 0x01, 0x80, 0x18, 0x01, 0x80, 0x18, 0x01, 0xFE, 0x19, 0xF1, 0x87, 0x18, + 0x31, 0x87, 0x19, 0xF1, 0xFE, 0xE0, 0x3F, 0x01, 0xF8, 0x0F, 0xC0, 0x7E, 0x03, 0xFF, 0x9F, 0xBE, 0xFC, 0x77, 0xE3, + 0xBF, 0x1D, 0xFB, 0xEF, 0xFE, 0x70, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xFB, 0xBF, 0xC7, 0xE1, 0xF1, 0xFB, 0xFF, + 0xE0, 0xFE, 0x3B, 0xE0, 0x38, 0x07, 0x01, 0xCF, 0xF0, 0x1C, 0x07, 0x01, 0xC0, 0xEC, 0x7B, 0xF8, 0xE1, 0xF8, 0xE3, + 0xDC, 0xE7, 0x0E, 0xE6, 0x07, 0xEE, 0x07, 0xFE, 0x07, 0xEE, 0x07, 0xE6, 0x07, 0xE6, 0x07, 0xE7, 0x0E, 0xE3, 0xDE, + 0xE1, 0xF8, 0x3F, 0xDE, 0x76, 0x1D, 0x87, 0x61, 0xDE, 0x73, 0xFC, 0xE7, 0x39, 0xDC, 0x77, 0x1F, 0x87, 0x7E, 0x2F, + 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1F, 0x3F, 0x18, 0x1C, 0x0F, 0xE7, 0xBB, 0x0D, 0x87, 0xC3, 0xE1, 0xF8, + 0xDE, 0xE7, 0xE0, 0xFE, 0xCE, 0xC7, 0xCE, 0xFE, 0xCF, 0xC3, 0xCF, 0xFE, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC0, + 0x3F, 0x8E, 0x63, 0x98, 0xE6, 0x31, 0x8C, 0x67, 0x19, 0xC6, 0xFF, 0xF0, 0x3C, 0x0C, 0x3F, 0x3F, 0x98, 0xEC, 0x7F, + 0xFB, 0x01, 0x80, 0xF6, 0x3F, 0x00, 0x67, 0x33, 0xBB, 0x8F, 0xF8, 0x3F, 0x81, 0xFC, 0x0F, 0xE0, 0xDD, 0x8C, 0xE6, + 0xE7, 0x38, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, + 0xE3, 0x7E, 0x3C, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, + 0xDE, 0xCE, 0xC7, 0x3F, 0x9C, 0xCE, 0x67, 0x33, 0x99, 0x8C, 0xC6, 0xE3, 0xE1, 0x80, 0xE1, 0xDC, 0x3B, 0xC7, 0x69, + 0xED, 0xBD, 0xBD, 0xF3, 0xBE, 0x67, 0xC0, 0xE0, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0x3F, 0x1F, + 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0xFF, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xFE, 0x77, 0xB0, 0xD8, 0x6C, 0x3E, 0x1B, 0x0D, 0xFE, 0xFE, 0x60, 0x30, 0x18, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0xE3, 0x71, 0x99, 0xCC, 0xE7, 0x61, + 0xB0, 0xF8, 0x38, 0x1C, 0x0E, 0x2E, 0x1E, 0x00, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x1F, 0x03, 0xFE, 0x3F, 0xF9, 0xDC, + 0xCC, 0xE7, 0xE7, 0x3B, 0x39, 0x99, 0xCC, 0xFF, 0xE3, 0xFE, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0xE3, 0x3B, 0x8F, 0x87, + 0x81, 0xC1, 0xF1, 0xF8, 0xCE, 0xE3, 0x80, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x3B, 0x1D, 0x8E, 0xFF, 0x80, 0xC0, + 0x60, 0xE3, 0xE3, 0xE3, 0xE3, 0x7F, 0x3F, 0x03, 0x03, 0x03, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, + 0xC6, 0x3C, 0x63, 0xFF, 0xF0, 0xC6, 0x36, 0x31, 0xB1, 0x8D, 0x8C, 0x6C, 0x63, 0x63, 0x1B, 0x18, 0xD8, 0xC6, 0xFF, + 0xF8, 0x00, 0xC0, 0x06, 0xF8, 0x0E, 0x03, 0x80, 0xFE, 0x3B, 0xCE, 0x33, 0x8C, 0xEF, 0x3F, 0x80, 0xC0, 0xF8, 0x1F, + 0x03, 0xFF, 0x7E, 0xEF, 0x8F, 0xF1, 0xFE, 0x77, 0xFE, 0xE0, 0xC0, 0xC0, 0xC0, 0xFE, 0xEF, 0xC7, 0xC7, 0xCF, 0xFE, + 0x7C, 0x6E, 0x07, 0x07, 0x3F, 0x07, 0x07, 0xDE, 0xFC, 0xC7, 0xEC, 0xFE, 0xCC, 0x7C, 0xC3, 0xFC, 0x3D, 0xC3, 0xCC, + 0x7C, 0xFE, 0xC7, 0xE0, 0x3F, 0x73, 0x63, 0x63, 0x73, 0x3F, 0x33, 0x73, 0xE3, 0x18, 0x0E, 0x03, 0x00, 0x03, 0xF3, + 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x3F, 0x1F, 0x80, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, + 0x30, 0x1E, 0xC7, 0xE0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, + 0x1C, 0x0E, 0x0E, 0x1C, 0xF3, 0x80, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0x3F, 0x7B, 0x60, 0x60, 0xFE, 0x60, + 0x60, 0x7B, 0x3F, 0x7E, 0xEF, 0x83, 0x83, 0xC1, 0xC1, 0xB7, 0xFE, 0xD8, 0x0D, 0xB6, 0xDB, 0x6C, 0xFF, 0xF0, 0x0C, + 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x18, 0x60, 0x00, 0x18, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x63, 0x9C, 0x3F, + 0x80, 0x73, 0x00, 0xE6, 0x01, 0xCF, 0xE3, 0x99, 0xC6, 0x31, 0xCC, 0x63, 0xB8, 0xCE, 0xE1, 0xFC, 0xC3, 0x06, 0x18, + 0x30, 0xC1, 0xFF, 0xEC, 0x33, 0xE1, 0x8F, 0x0C, 0x78, 0x67, 0xC3, 0xF0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, + 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, 0x0C, 0x1C, 0x18, 0x00, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, 0xDE, + 0xCE, 0xC7, 0x30, 0x38, 0x18, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x36, 0x1F, 0x00, 0x1C, + 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xFF, 0x18, 0x18, 0x18, 0x01, 0x80, 0xFF, 0x81, 0x80, 0x18, 0x01, 0x80, 0x1F, 0xE1, 0x9F, 0x18, 0x71, + 0x83, 0x18, 0x71, 0x9F, 0x1F, 0xE0, 0x38, 0x0E, 0x03, 0x80, 0xE0, 0xFF, 0x0E, 0x03, 0x80, 0xFE, 0x3B, 0xCE, 0x33, + 0x8C, 0xEF, 0x3F, 0x80, 0x1F, 0xC1, 0xE7, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xBF, 0xFD, 0xDF, 0xEC, 0x07, 0x70, 0x33, + 0x83, 0x8F, 0x38, 0x3F, 0x80, 0x3F, 0x1F, 0xE6, 0x19, 0xF6, 0xFF, 0xD8, 0x66, 0x19, 0xCE, 0x3F, 0x00, 0xE0, 0x7B, + 0x83, 0xE6, 0x0C, 0x1C, 0x70, 0x71, 0xC0, 0xC6, 0x03, 0xB8, 0x0E, 0xE0, 0x1F, 0x00, 0x7C, 0x00, 0xE0, 0x03, 0x80, + 0xE3, 0xF8, 0xC6, 0x71, 0x98, 0x76, 0x0F, 0x83, 0xC0, 0xF0, 0x1C, 0x00, 0x3F, 0x01, 0xE0, 0x00, 0x0E, 0x0C, 0xE1, + 0xCE, 0x3C, 0xE7, 0xCE, 0x6C, 0xEE, 0xCE, 0xCC, 0xF8, 0xCF, 0x8C, 0xF0, 0xCE, 0x0C, 0xE0, 0xF0, 0x07, 0x00, 0xE0, + 0x0E, 0x7E, 0x0F, 0x00, 0x03, 0x1C, 0xC7, 0x33, 0xCC, 0xF3, 0x6C, 0xF3, 0x3C, 0xCE, 0x33, 0x8F, 0x01, 0xC0, 0xE0, + 0x38, 0x70, 0x1F, 0x81, 0xC0, 0x38, 0x07, 0x00, 0xFF, 0x1D, 0xF3, 0x8E, 0x70, 0xCE, 0x19, 0xC7, 0x39, 0xE7, 0xF8, + 0x70, 0x38, 0x3F, 0x0E, 0x07, 0xF3, 0xBD, 0xCE, 0xEF, 0x7F, 0x00, 0xFE, 0x77, 0xB8, 0xFC, 0x3E, 0x9F, 0xFF, 0xBF, + 0xFE, 0xE7, 0x71, 0xB8, 0x1C, 0x00, 0xFE, 0x77, 0xB0, 0xD8, 0x6C, 0x3E, 0x1B, 0x7D, 0xFE, 0xFE, 0x63, 0x30, 0xD8, + 0x00, 0x07, 0x07, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0x0C, 0x3F, 0xF0, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x7F, 0xB8, 0x1C, 0x0E, 0x07, 0x07, 0xE1, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x00, + 0x7E, 0xE1, 0xC3, 0x8F, 0xCE, 0x1C, 0x38, 0x70, 0xFF, 0x70, 0x38, 0x1C, 0x0F, 0xE7, 0x3B, 0x8F, 0xC3, 0xE1, 0xF0, + 0xF8, 0x7C, 0x30, 0x38, 0x38, 0x78, 0xFE, 0xC0, 0xC0, 0xFE, 0xEE, 0xC7, 0xC3, 0xC3, 0xC7, 0x07, 0x0E, 0x1E, 0xF3, + 0x8E, 0x73, 0x9C, 0x3B, 0xB8, 0x1F, 0xF0, 0x0F, 0xF0, 0x07, 0xE0, 0x0F, 0xF0, 0x1B, 0xB0, 0x3B, 0x98, 0x73, 0x9C, + 0x73, 0x8C, 0xE3, 0x8F, 0x00, 0x03, 0x00, 0x03, 0x00, 0x03, 0x67, 0x31, 0xDD, 0xC3, 0xFE, 0x07, 0xF0, 0x1F, 0x80, + 0x7F, 0x03, 0x7E, 0x19, 0xDC, 0xE7, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x7F, 0x3B, 0x80, 0xE0, 0x70, 0xF1, 0xF8, 0x3E, + 0x07, 0x01, 0x81, 0xF9, 0xFF, 0xE1, 0xC0, 0xE0, 0xF0, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0x18, + 0x1C, 0x38, 0xE3, 0x9C, 0xE3, 0x98, 0x76, 0x0F, 0xC1, 0xF0, 0x3E, 0x07, 0x60, 0xEE, 0x1C, 0xE3, 0x8E, 0x70, 0xE0, + 0x0C, 0x01, 0x80, 0x30, 0xC7, 0x67, 0x37, 0x1F, 0x0F, 0x06, 0xC3, 0x71, 0x9C, 0xC7, 0x81, 0xC0, 0xE0, 0x70, 0xE0, + 0xFC, 0x3B, 0xEE, 0x7F, 0x8F, 0xE1, 0xF8, 0x3F, 0x87, 0xF8, 0xFB, 0x9C, 0x33, 0x87, 0x70, 0x70, 0xC3, 0xB1, 0xCD, + 0xE3, 0x78, 0xFC, 0x37, 0x8D, 0xF3, 0x0E, 0xC1, 0x80, 0x71, 0xFF, 0x79, 0xDE, 0x3F, 0x87, 0xE0, 0xF8, 0x1F, 0x83, + 0xF8, 0x77, 0x8E, 0x79, 0xC7, 0x38, 0x70, 0x73, 0xBF, 0xC7, 0xE1, 0xF0, 0x7C, 0x1F, 0x87, 0x71, 0xCE, 0x73, 0x80, + 0xF8, 0x70, 0xC7, 0x06, 0x70, 0x37, 0x01, 0xF0, 0x0F, 0x80, 0x7E, 0x03, 0x78, 0x19, 0xE0, 0xC7, 0x06, 0x1C, 0x30, + 0x70, 0xF9, 0xC7, 0x70, 0xFC, 0x1F, 0x03, 0xE0, 0x7E, 0x0F, 0xE1, 0xDE, 0x39, 0xC0, 0xE1, 0xDC, 0x3B, 0x87, 0x70, + 0xEE, 0x1D, 0xFF, 0xB8, 0x77, 0x0E, 0xE1, 0xDC, 0x3B, 0x87, 0x70, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0xC3, 0x61, 0xB0, + 0xD8, 0x6F, 0xF6, 0x1B, 0x0D, 0x86, 0xC3, 0x80, 0xC0, 0x60, 0xE1, 0xFF, 0x0E, 0x38, 0x71, 0xC3, 0x8E, 0x1C, 0x7F, + 0xE3, 0x87, 0x1C, 0x38, 0xE1, 0xC7, 0x0E, 0x38, 0x71, 0xC3, 0x80, 0xC3, 0xF0, 0xCC, 0x33, 0x0C, 0xFF, 0x30, 0xCC, + 0x33, 0x0C, 0xC3, 0x00, 0xFF, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xFC, 0xE1, 0xFE, 0xE1, 0xC7, 0xE1, + 0xC3, 0xE1, 0xC3, 0xE1, 0xC3, 0xE1, 0xC3, 0xE1, 0xC7, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x3C, 0xFF, 0x03, 0x1C, 0x0C, + 0x70, 0x31, 0xFC, 0xC7, 0x7B, 0x1C, 0x6C, 0x71, 0xB1, 0xC7, 0xC7, 0x18, 0x00, 0x60, 0x03, 0x80, 0x3C, 0x1F, 0xC1, + 0xE6, 0x1C, 0x00, 0xE0, 0x06, 0x1E, 0x31, 0xF9, 0x9C, 0xEC, 0xE7, 0x77, 0x3B, 0xB9, 0x8F, 0xDC, 0x3F, 0xC0, 0x38, + 0x00, 0xF0, 0x03, 0xC0, 0x3E, 0x1E, 0x06, 0x79, 0xBF, 0xEC, 0xDB, 0x36, 0xDD, 0xFE, 0x3F, 0x01, 0xE0, 0x3C, 0x02, + 0x03, 0xF8, 0xF3, 0x38, 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0x0E, 0x01, + 0xC0, 0x78, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x0C, 0x0E, 0x1C, 0xFF, 0xC3, 0x00, 0xC0, 0x30, + 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x3C, 0x07, 0x01, 0xC0, 0x70, 0xFF, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x1C, 0x0C, 0x0C, 0xE1, 0xDC, 0x39, 0xCE, 0x1D, 0xC3, 0xF0, 0x3C, 0x07, 0x80, 0x60, 0x0C, 0x01, + 0x80, 0x30, 0x06, 0x00, 0xE3, 0xF1, 0x99, 0xCC, 0xE7, 0x61, 0xF0, 0xF8, 0x78, 0x1C, 0x0C, 0x06, 0x03, 0x00, 0xE1, + 0xDC, 0x39, 0xCE, 0x19, 0xC3, 0xF0, 0x3C, 0x07, 0x83, 0xFC, 0x0C, 0x01, 0x80, 0x30, 0x06, 0x00, 0xE3, 0xF1, 0x99, + 0xCE, 0xE7, 0x61, 0xF0, 0xF8, 0x78, 0x7F, 0x0C, 0x06, 0x03, 0x00, 0xE1, 0xCE, 0x38, 0xEE, 0x1F, 0x81, 0xF0, 0x1C, + 0x07, 0x80, 0xF8, 0x3B, 0x8E, 0x71, 0xC7, 0x70, 0x70, 0x06, 0x00, 0xC0, 0x18, 0xE3, 0x3B, 0x8F, 0x87, 0x81, 0xC1, + 0xF1, 0xF8, 0xCE, 0xE3, 0x80, 0xC0, 0x60, 0xFF, 0x98, 0x38, 0x30, 0x70, 0x60, 0xE0, 0xC1, 0xC1, 0x83, 0x83, 0x07, + 0x06, 0x0E, 0x0C, 0x1C, 0x18, 0x38, 0x30, 0x70, 0x60, 0xFF, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0xFE, 0xC7, + 0x18, 0xE3, 0x1C, 0x63, 0x8C, 0x71, 0x8E, 0x31, 0xC6, 0x3F, 0xE0, 0x0C, 0x01, 0x80, 0x61, 0xCC, 0x39, 0x87, 0x30, + 0xE6, 0x1C, 0xC3, 0x9E, 0xF1, 0xFE, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0x03, 0xE3, 0x71, + 0xB8, 0xDC, 0x67, 0xF1, 0xF8, 0x0C, 0x06, 0x03, 0x80, 0xC0, 0x60, 0x61, 0xD8, 0x76, 0x1D, 0x87, 0x61, 0xDB, 0x77, + 0xFC, 0xFF, 0x0D, 0xC3, 0x70, 0x1C, 0x07, 0xE3, 0xE3, 0xFB, 0xFB, 0x7F, 0x7F, 0x1B, 0x1B, 0x03, 0xE0, 0x70, 0x38, + 0x1C, 0x0F, 0xF7, 0xBF, 0x8F, 0xC3, 0xE1, 0xF0, 0xF8, 0x7C, 0x30, 0xC0, 0xC0, 0xC0, 0xC0, 0xFE, 0xEE, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x07, 0xF0, 0x3F, 0xE0, 0xC3, 0xB7, 0x06, 0xFC, 0x1D, 0xFF, 0xF1, 0x80, 0x07, 0x00, + 0x1C, 0x00, 0x30, 0x00, 0xF3, 0x81, 0xFE, 0x0F, 0xC3, 0xBB, 0x63, 0xFC, 0x7F, 0xFE, 0x70, 0x06, 0x00, 0xF6, 0x0F, + 0xC0, 0x07, 0xF0, 0x3F, 0xE0, 0xC3, 0xB7, 0x06, 0xFC, 0x1D, 0xFF, 0xF1, 0x80, 0x07, 0x00, 0x1C, 0x00, 0x30, 0x00, + 0xF3, 0x81, 0xFE, 0x01, 0xC0, 0x07, 0x00, 0x1C, 0x00, 0x0F, 0xC3, 0xBB, 0x63, 0xFC, 0x7F, 0xFE, 0x70, 0x06, 0x00, + 0xF6, 0x0F, 0xC0, 0x60, 0x0C, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x06, 0xE0, 0x0F, 0x80, 0x00, 0x07, 0x9C, 0x77, + 0x39, 0xC7, 0x77, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xE0, 0x1F, 0xE0, 0x6E, 0xE1, 0xDC, 0xC7, 0x39, 0xCE, 0x71, 0xF8, + 0xE3, 0x80, 0x0D, 0x80, 0x7C, 0x00, 0x00, 0xCE, 0x67, 0x77, 0x1F, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, 0xC1, 0xBB, 0x19, + 0xCD, 0xCE, 0x70, 0xE3, 0xF3, 0xBB, 0x9F, 0x8F, 0xC7, 0xF3, 0xBD, 0xC7, 0xE1, 0xF0, 0xF8, 0x7C, 0x30, 0x38, 0xF8, + 0x78, 0xC7, 0xCE, 0xDC, 0xF8, 0xFE, 0xDE, 0xC7, 0xC3, 0xC3, 0x07, 0x0E, 0x1E, 0x1F, 0xE0, 0xE7, 0x07, 0x38, 0x39, + 0xC1, 0xCE, 0x0E, 0x70, 0x73, 0x83, 0x1C, 0x18, 0xE1, 0xC7, 0x1C, 0x39, 0xC1, 0xE0, 0x03, 0x00, 0x38, 0x01, 0x80, + 0x3F, 0x87, 0x30, 0xE6, 0x1C, 0xC3, 0x98, 0x63, 0x0C, 0x63, 0x8C, 0xE1, 0xE0, 0x1C, 0x07, 0x00, 0xE0, 0xE1, 0xF8, + 0x7E, 0x1F, 0x87, 0xE1, 0xFF, 0xFE, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0x01, 0xC0, 0x70, 0x38, 0xC3, 0xC3, + 0xC3, 0xC3, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0x03, 0x07, 0x0E, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCF, 0xFC, + 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1E, 0x00, 0x60, 0x0E, 0x00, 0xC0, 0xC3, 0x30, 0xCC, 0x33, 0x0C, + 0xFF, 0x30, 0xCC, 0x33, 0x0C, 0xC3, 0x80, 0x60, 0x38, 0x0C, 0xC3, 0xE1, 0xF0, 0xF8, 0x7C, 0x3E, 0x1F, 0xDE, 0xFF, + 0x03, 0x81, 0xC0, 0xE0, 0xF0, 0x60, 0x30, 0x18, 0xE3, 0xE3, 0xE3, 0xE3, 0x7F, 0x3F, 0x03, 0x03, 0x0F, 0x0E, 0x0E, + 0x60, 0x31, 0xE0, 0xE3, 0xC1, 0xC7, 0xC7, 0xCD, 0x8F, 0x99, 0x37, 0x33, 0x6E, 0x66, 0x9C, 0xC7, 0x39, 0x8E, 0x33, + 0x00, 0x66, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0x80, 0x06, 0x00, 0xE1, 0xCE, 0x1C, 0xF1, 0xCD, 0x3C, 0xDB, 0xCD, 0xEE, + 0xCE, 0xEC, 0xCE, 0xC0, 0xF0, 0x07, 0x00, 0x70, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1B, 0x01, 0xF0, 0x00, 0x00, + 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, + 0x3E, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1B, 0x81, 0xB8, 0x00, 0x00, 0xE0, 0x0E, 0x01, + 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, 0x36, 0x00, 0x7E, + 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x03, 0xFE, 0x03, 0xC0, 0x07, 0xC0, 0x06, 0xC0, 0x0E, 0xC0, 0x1C, + 0xFE, 0x18, 0xC0, 0x38, 0xC0, 0x3F, 0xC0, 0x70, 0xC0, 0xE0, 0xC0, 0xE0, 0xFF, 0x7F, 0xF8, 0xBF, 0xF0, 0x79, 0xC0, + 0xC3, 0x7F, 0xFF, 0xDC, 0x0E, 0x38, 0x1D, 0xF6, 0x7F, 0xF8, 0x7E, 0x1E, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, + 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x3F, 0x0F, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, + 0x1E, 0xC7, 0xE0, 0x3F, 0x86, 0x78, 0x03, 0x80, 0x70, 0x06, 0x00, 0xDF, 0xFB, 0x03, 0x60, 0xEE, 0x1C, 0xEF, 0x0F, + 0xC0, 0x7E, 0x37, 0x80, 0xE0, 0x7F, 0xFB, 0x1D, 0x8E, 0xFE, 0x3E, 0x00, 0x1B, 0x83, 0x70, 0x00, 0x1F, 0xC3, 0x3C, + 0x01, 0xC0, 0x38, 0x03, 0x00, 0x6F, 0xFD, 0x81, 0xB0, 0x77, 0x0E, 0x77, 0x87, 0xE0, 0x37, 0x1B, 0x80, 0x0F, 0xC6, + 0xF0, 0x1C, 0x0F, 0xFF, 0x63, 0xB1, 0xDF, 0xC7, 0xC0, 0x06, 0xE0, 0x0D, 0xC0, 0x00, 0x07, 0x9C, 0x77, 0x39, 0xC7, + 0x77, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xE0, 0x1F, 0xE0, 0x6E, 0xE1, 0xDC, 0xC7, 0x39, 0xCE, 0x71, 0xF8, 0xE3, 0x80, + 0x0D, 0x80, 0x6C, 0x00, 0x00, 0xCE, 0x67, 0x77, 0x1F, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, 0xC1, 0xBB, 0x19, 0xCD, 0xCE, + 0x70, 0x76, 0x3B, 0x00, 0x0F, 0xE7, 0x70, 0x1C, 0x0E, 0x1E, 0x3F, 0x07, 0xC0, 0xE0, 0x30, 0x3F, 0x3F, 0xFC, 0x7E, + 0x7E, 0x00, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0x7F, 0x83, 0x81, 0xC1, 0xC1, 0xC0, 0xF8, 0x3E, + 0x07, 0x01, 0x81, 0xF9, 0xFF, 0xE0, 0x7F, 0x03, 0x81, 0x81, 0xC0, 0xC0, 0xF0, 0x3C, 0x07, 0x03, 0x81, 0xDB, 0xCF, + 0xC0, 0x3F, 0x00, 0x00, 0x03, 0x83, 0xE1, 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, + 0x0C, 0x7E, 0x00, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x3F, 0x0F, 0xC0, 0x03, 0x83, 0xE1, + 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, 0x0C, 0x7E, 0x7E, 0x00, 0xC7, 0xC7, 0xCF, + 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, + 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, + 0x66, 0x1B, 0x87, 0x61, 0x98, 0x67, 0xF8, 0xFC, 0x1F, 0xC1, 0xEF, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xBF, 0xFD, 0x80, + 0xEC, 0x07, 0x70, 0x73, 0x83, 0x8F, 0x78, 0x3F, 0x80, 0x3F, 0x1F, 0xE6, 0x19, 0x86, 0xFF, 0xD8, 0x66, 0x19, 0xFE, + 0x3F, 0x00, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x7F, 0xFB, 0x01, 0xD8, + 0x0E, 0xE0, 0xE7, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0xFF, 0x61, + 0x98, 0x67, 0xF8, 0xFC, 0x76, 0x1D, 0x80, 0x03, 0xF8, 0xEF, 0x80, 0xE0, 0x1C, 0x07, 0x3F, 0xC0, 0x70, 0x1C, 0x07, + 0x03, 0xB1, 0xEF, 0xE0, 0x6C, 0x6C, 0x00, 0x7C, 0x6E, 0x07, 0x07, 0x3F, 0x07, 0x07, 0xDE, 0xFC, 0x3F, 0x00, 0x00, + 0x03, 0x87, 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, 0x0F, 0x80, 0x3E, 0x00, 0x00, + 0x1C, 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x3B, 0x0E, 0xC0, 0x03, 0x87, + 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, 0x0F, 0x80, 0x36, 0x1B, 0x00, 0x1C, 0x6E, + 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x0D, 0x87, 0xE1, 0xB0, 0x00, 0xE1, 0xF8, + 0x77, 0x1D, 0xC6, 0x3B, 0x8E, 0xC1, 0xF0, 0x7C, 0x0E, 0x03, 0x85, 0xC3, 0xE0, 0x1B, 0x1F, 0x8D, 0x80, 0x0E, 0x37, + 0x19, 0x9C, 0xCE, 0x76, 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, 0x3B, 0x0E, 0xC0, 0x01, 0x87, 0x61, 0xD8, + 0x76, 0x1D, 0x87, 0x61, 0xDE, 0xF3, 0xFC, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x76, 0x76, 0x00, 0xE3, 0xE3, 0xE3, 0xE3, + 0x7F, 0x3F, 0x03, 0x03, 0x03, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0x30, 0x30, + 0x30, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xF1, 0xC7, 0x00, 0x1D, 0x80, 0xEC, 0x00, 0x01, 0xC0, 0x7E, 0x03, 0xF0, + 0x1F, 0x80, 0xFC, 0x07, 0xFF, 0x3F, 0x7D, 0xF8, 0xEF, 0xC7, 0x7E, 0x3B, 0xF7, 0xDF, 0xFC, 0xE0, 0x1B, 0x03, 0x60, + 0x00, 0x60, 0x7C, 0x0F, 0x81, 0xFF, 0xBF, 0x77, 0xC7, 0xF8, 0xFF, 0x3B, 0xFF, 0x70, 0x7F, 0xC0, 0x7F, 0xFF, 0xC0, + 0x7F, 0xFF, 0xC0, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x80, 0x6F, 0xF0, 0x7F, 0xEF, 0x3C, 0xF3, 0xC0, 0x7D, 0xF7, 0xDF, + 0xF3, 0xC0, 0x6C, 0xDB, 0xB7, 0xE0, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x79, 0xF7, 0xDE, 0xE3, + 0x8F, 0xC7, 0x1C, 0x7C, 0x70, 0x03, 0x67, 0x00, 0x3B, 0xB8, 0x01, 0xDF, 0x80, 0x06, 0xD8, 0x00, 0x3F, 0xC0, 0x00, + 0x0D, 0xE7, 0xC0, 0xFF, 0xFE, 0x06, 0xCF, 0x30, 0x76, 0x79, 0x83, 0x3F, 0xFC, 0x38, 0xF3, 0xE0, 0x3B, 0x9D, 0xC7, + 0x38, 0xE0, 0xE3, 0x1C, 0x77, 0x33, 0x80, 0x03, 0x83, 0x81, 0x81, 0xC0, 0xC0, 0xE0, 0x60, 0x70, 0x30, 0x38, 0x38, + 0x18, 0x00, 0x0F, 0x87, 0xE3, 0x80, 0xE0, 0xFF, 0x0C, 0x03, 0x03, 0xFC, 0x38, 0x0E, 0x01, 0xDC, 0x3E, 0xFF, 0x86, + 0x03, 0x01, 0xE1, 0xF3, 0xE1, 0xBC, 0x3E, 0x7C, 0x36, 0x03, 0x01, 0x80, 0x08, 0x1F, 0x89, 0xC0, 0x70, 0x3F, 0xFC, + 0x38, 0x78, 0xFF, 0xB0, 0x18, 0x0E, 0x33, 0xF8, 0x60, 0xFF, 0x8F, 0x03, 0x9F, 0xE0, 0xE1, 0xE3, 0xE1, 0xC0, 0x70, + 0x1C, 0x07, 0x03, 0x80, 0x7E, 0x1B, 0xC0, 0x38, 0x06, 0x01, 0x8F, 0xE7, 0xB9, 0x86, 0xE1, 0xB8, 0x6E, 0x39, 0xDC, + 0x3E, 0x00, 0x0E, 0x01, 0xC0, 0x7C, 0x0D, 0x83, 0xB8, 0x63, 0x0C, 0x73, 0x8E, 0x60, 0xDC, 0x1F, 0x83, 0xFF, 0xF0, + 0xFF, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xC0, 0xFF, 0xF0, + 0x1C, 0x07, 0x03, 0x80, 0xE0, 0x30, 0x38, 0x38, 0x1C, 0x1C, 0x0E, 0x0E, 0x07, 0xFC, 0xFF, 0x80, 0x03, 0x83, 0x81, + 0x81, 0xC0, 0xC0, 0xE0, 0x60, 0x70, 0x30, 0x38, 0x38, 0x18, 0x00, 0x77, 0x00, 0xC0, 0x70, 0x18, 0x06, 0x01, 0x80, + 0xEF, 0x33, 0xCC, 0x3F, 0x07, 0x81, 0xE0, 0x78, 0x0C, 0x00, 0x7F, 0xBF, 0xFC, 0xCF, 0xFF, 0x7F, 0x80, 0x1E, 0x70, + 0xC1, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x06, 0x1C, 0x70, 0x7D, 0xFF, 0xC0, 0x00, 0x07, 0xDF, 0xFC, + 0x03, 0x03, 0xBF, 0xE1, 0x81, 0xC7, 0xFC, 0xC0, 0x60, 0x03, 0x87, 0x9F, 0x0E, 0x03, 0xC0, 0x78, 0x0E, 0x00, 0xFF, + 0x80, 0x70, 0x3E, 0x03, 0xC0, 0x70, 0xF3, 0xE1, 0xC0, 0x00, 0xFF, 0x80, }; static const EpdGlyph pixelarial14Glyphs[] = { + {0, 0, 0, 0, 0, 0, 0}, // + {0, 0, 0, 0, 0, 0, 0}, //  + {0, 0, 4, 0, 0, 0, 0}, // + {0, 0, 4, 0, 0, 0, 0}, // + {0, 0, 0, 0, 0, 0, 0}, //  {0, 0, 4, 0, 0, 0, 0}, // {2, 13, 3, 0, 13, 4, 0}, // ! {4, 6, 5, 0, 13, 3, 4}, // " @@ -167,18 +548,494 @@ static const EpdGlyph pixelarial14Glyphs[] = { {2, 13, 3, 0, 13, 4, 1110}, // | {4, 17, 5, 0, 13, 9, 1114}, // } {11, 4, 12, 0, 9, 6, 1123}, // ~ - {3, 6, 4, 0, 13, 3, 1129}, // ‘ - {3, 6, 4, 0, 13, 3, 1132}, // ’ - {6, 6, 6, 0, 13, 5, 1135}, // “ - {6, 6, 6, 0, 13, 5, 1140}, // ” + {0, 0, 4, 0, 0, 0, 1129}, //   + {3, 12, 5, 1, 9, 5, 1129}, // ¡ + {8, 13, 10, 1, 11, 13, 1134}, // ¢ + {8, 12, 10, 1, 12, 12, 1147}, // £ + {9, 9, 10, 0, 11, 11, 1159}, // ¤ + {10, 12, 10, 0, 12, 15, 1170}, // ¥ + {3, 16, 5, 1, 13, 6, 1185}, // ¦ + {8, 14, 8, 0, 13, 14, 1191}, // § + {6, 2, 6, 0, 12, 2, 1205}, // ¨ + {12, 12, 14, 1, 12, 18, 1207}, // © + {6, 6, 7, 0, 12, 5, 1225}, // ª + {8, 7, 8, 0, 8, 7, 1230}, // « + {9, 5, 10, 0, 6, 6, 1237}, // ¬ + {5, 1, 5, 0, 5, 1, 1243}, // ­ + {12, 12, 14, 1, 12, 18, 1244}, // ® + {6, 1, 6, 0, 12, 1, 1262}, // ¯ + {6, 5, 6, 0, 13, 4, 1263}, // ° + {9, 10, 10, 0, 10, 12, 1267}, // ± + {6, 7, 6, 0, 12, 6, 1279}, // ² + {6, 7, 6, 0, 12, 6, 1285}, // ³ + {4, 3, 6, 2, 13, 2, 1291}, // ´ + {8, 12, 10, 1, 9, 12, 1293}, // µ + {10, 15, 11, 0, 12, 19, 1305}, // ¶ + {4, 2, 4, 0, 6, 1, 1324}, // · + {4, 3, 6, 1, 0, 2, 1325}, // ¸ + {5, 7, 6, 0, 12, 5, 1327}, // ¹ + {7, 6, 8, 0, 12, 6, 1332}, // º + {8, 7, 8, 0, 8, 7, 1338}, // » + {15, 12, 15, 0, 12, 23, 1345}, // ¼ + {15, 12, 15, 0, 12, 23, 1368}, // ½ + {15, 12, 15, 0, 12, 23, 1391}, // ¾ + {7, 12, 7, 0, 9, 11, 1414}, // ¿ + {12, 16, 11, 0, 16, 24, 1425}, // À + {12, 16, 11, 0, 16, 24, 1449}, // Á + {12, 16, 11, 0, 16, 24, 1473}, //  + {12, 16, 11, 0, 16, 24, 1497}, // à + {12, 15, 11, 0, 15, 23, 1521}, // Ä + {12, 15, 11, 0, 15, 23, 1544}, // Å + {16, 12, 16, 0, 12, 24, 1567}, // Æ + {11, 16, 11, 0, 13, 22, 1591}, // Ç + {9, 16, 10, 1, 16, 18, 1613}, // È + {9, 16, 10, 1, 16, 18, 1631}, // É + {9, 16, 10, 1, 16, 18, 1649}, // Ê + {9, 15, 10, 1, 15, 17, 1667}, // Ë + {4, 16, 5, 0, 16, 8, 1684}, // Ì + {4, 16, 5, 1, 16, 8, 1692}, // Í + {6, 16, 5, -1, 16, 12, 1700}, // Î + {6, 15, 5, -1, 15, 12, 1712}, // Ï + {12, 12, 12, 0, 12, 18, 1724}, // Ð + {10, 16, 12, 1, 16, 20, 1742}, // Ñ + {13, 16, 13, 0, 16, 26, 1762}, // Ò + {13, 16, 13, 0, 16, 26, 1788}, // Ó + {13, 16, 13, 0, 16, 26, 1814}, // Ô + {13, 16, 13, 0, 16, 26, 1840}, // Õ + {13, 15, 13, 0, 15, 25, 1866}, // Ö + {8, 7, 10, 1, 9, 7, 1891}, // × + {13, 12, 13, 0, 12, 20, 1898}, // Ø + {10, 16, 12, 1, 16, 20, 1918}, // Ù + {10, 16, 12, 1, 16, 20, 1938}, // Ú + {10, 16, 12, 1, 16, 20, 1958}, // Û + {10, 15, 12, 1, 15, 19, 1978}, // Ü + {11, 16, 10, 0, 16, 22, 1997}, // Ý + {9, 12, 10, 1, 12, 14, 2019}, // Þ + {9, 13, 11, 1, 13, 15, 2033}, // ß + {8, 13, 9, 0, 13, 13, 2048}, // à + {8, 13, 9, 0, 13, 13, 2061}, // á + {8, 13, 9, 0, 13, 13, 2074}, // â + {8, 13, 9, 0, 13, 13, 2087}, // ã + {8, 12, 9, 0, 12, 12, 2100}, // ä + {8, 14, 9, 0, 14, 14, 2112}, // å + {14, 9, 15, 0, 9, 16, 2126}, // æ + {8, 12, 8, 0, 9, 12, 2142}, // ç + {9, 13, 10, 0, 13, 15, 2154}, // è + {9, 13, 10, 0, 13, 15, 2169}, // é + {9, 13, 10, 0, 13, 15, 2184}, // ê + {9, 12, 10, 0, 12, 14, 2199}, // ë + {4, 13, 4, 0, 13, 7, 2213}, // ì + {4, 13, 4, 1, 13, 7, 2220}, // í + {6, 13, 4, -1, 13, 10, 2227}, // î + {6, 12, 4, -1, 12, 9, 2237}, // ï + {10, 13, 10, 0, 13, 17, 2246}, // ð + {8, 13, 10, 1, 13, 13, 2263}, // ñ + {10, 13, 10, 0, 13, 17, 2276}, // ò + {10, 13, 10, 0, 13, 17, 2293}, // ó + {10, 13, 10, 0, 13, 17, 2310}, // ô + {10, 13, 10, 0, 13, 17, 2327}, // õ + {10, 12, 10, 0, 12, 15, 2344}, // ö + {9, 9, 10, 0, 9, 11, 2359}, // ÷ + {10, 9, 10, 0, 9, 12, 2370}, // ø + {8, 13, 10, 1, 13, 13, 2382}, // ù + {8, 13, 10, 1, 13, 13, 2395}, // ú + {8, 13, 10, 1, 13, 13, 2408}, // û + {8, 12, 10, 1, 12, 12, 2421}, // ü + {9, 16, 8, 0, 13, 18, 2433}, // ý + {9, 16, 10, 1, 13, 18, 2451}, // þ + {9, 15, 8, 0, 12, 17, 2469}, // ÿ + {12, 15, 11, 0, 15, 23, 2486}, // Ā + {8, 12, 9, 0, 12, 12, 2509}, // ā + {12, 15, 11, 0, 15, 23, 2521}, // Ă + {8, 12, 9, 0, 12, 12, 2544}, // ă + {12, 15, 11, 0, 12, 23, 2556}, // Ą + {8, 12, 9, 0, 9, 12, 2579}, // ą + {11, 16, 11, 0, 16, 22, 2591}, // Ć + {8, 13, 8, 0, 13, 13, 2613}, // ć + {11, 16, 11, 0, 16, 22, 2626}, // Ĉ + {8, 13, 8, 0, 13, 13, 2648}, // ĉ + {11, 15, 11, 0, 15, 21, 2661}, // Ċ + {8, 12, 8, 0, 12, 12, 2682}, // ċ + {11, 16, 11, 0, 16, 22, 2694}, // Č + {8, 13, 8, 0, 13, 13, 2716}, // č + {11, 16, 12, 1, 16, 22, 2729}, // Ď + {12, 13, 11, 0, 13, 20, 2751}, // ď + {12, 12, 12, 0, 12, 18, 2771}, // Đ + {10, 13, 10, 0, 13, 17, 2789}, // đ + {9, 15, 10, 1, 15, 17, 2806}, // Ē + {9, 12, 10, 0, 12, 14, 2823}, // ē + {9, 15, 10, 1, 15, 17, 2837}, // Ĕ + {9, 12, 10, 0, 12, 14, 2854}, // ĕ + {9, 15, 10, 1, 15, 17, 2868}, // Ė + {9, 12, 10, 0, 12, 14, 2885}, // ė + {9, 15, 10, 1, 12, 17, 2899}, // Ę + {9, 12, 10, 0, 9, 14, 2916}, // ę + {9, 16, 10, 1, 16, 18, 2930}, // Ě + {9, 14, 10, 0, 14, 16, 2948}, // ě + {11, 16, 11, 0, 16, 22, 2964}, // Ĝ + {9, 16, 10, 0, 13, 18, 2986}, // ĝ + {11, 15, 11, 0, 15, 21, 3004}, // Ğ + {9, 15, 10, 0, 12, 17, 3025}, // ğ + {11, 15, 11, 0, 15, 21, 3042}, // Ġ + {9, 15, 10, 0, 12, 17, 3063}, // ġ + {11, 15, 11, 0, 12, 21, 3080}, // Ģ + {9, 16, 10, 0, 13, 18, 3101}, // ģ + {10, 16, 12, 1, 16, 20, 3119}, // Ĥ + {9, 16, 10, 0, 16, 18, 3139}, // ĥ + {13, 12, 12, 0, 12, 20, 3157}, // Ħ + {9, 13, 10, 0, 13, 15, 3177}, // ħ + {7, 16, 5, -1, 16, 14, 3192}, // Ĩ + {6, 13, 4, -1, 13, 10, 3206}, // ĩ + {6, 15, 5, -1, 15, 12, 3216}, // Ī + {6, 12, 4, -1, 12, 9, 3228}, // ī + {5, 15, 5, 0, 15, 10, 3237}, // Ĭ + {6, 12, 4, -1, 12, 9, 3247}, // ĭ + {4, 15, 5, 0, 12, 8, 3256}, // Į + {4, 16, 4, 0, 13, 8, 3264}, // į + {3, 15, 5, 1, 15, 6, 3272}, // İ + {2, 9, 4, 1, 9, 3, 3278}, // ı + {11, 12, 13, 1, 12, 17, 3281}, // IJ + {7, 16, 9, 1, 13, 14, 3298}, // ij + {9, 16, 9, 0, 16, 18, 3312}, // Ĵ + {7, 16, 4, -2, 13, 14, 3330}, // ĵ + {10, 15, 11, 1, 12, 19, 3344}, // Ķ + {8, 16, 9, 1, 13, 16, 3363}, // ķ + {8, 9, 9, 1, 9, 9, 3379}, // ĸ + {8, 16, 9, 1, 16, 16, 3388}, // Ĺ + {4, 16, 5, 1, 16, 8, 3404}, // ĺ + {8, 15, 9, 1, 12, 15, 3412}, // Ļ + {5, 16, 5, 0, 13, 10, 3427}, // ļ + {8, 12, 9, 1, 12, 12, 3437}, // Ľ + {5, 13, 5, 1, 13, 9, 3449}, // ľ + {8, 12, 9, 1, 12, 12, 3458}, // Ŀ + {6, 13, 6, 1, 13, 10, 3470}, // ŀ + {10, 12, 9, -1, 12, 15, 3480}, // Ł + {5, 13, 5, 0, 13, 9, 3495}, // ł + {10, 16, 12, 1, 16, 20, 3504}, // Ń + {8, 13, 10, 1, 13, 13, 3524}, // ń + {10, 15, 12, 1, 12, 19, 3537}, // Ņ + {8, 12, 10, 1, 9, 12, 3556}, // ņ + {10, 16, 12, 1, 16, 20, 3568}, // Ň + {8, 13, 10, 1, 13, 13, 3588}, // ň + {9, 14, 10, 0, 14, 16, 3601}, // ʼn + {10, 15, 12, 1, 12, 19, 3617}, // Ŋ + {8, 12, 10, 1, 9, 12, 3636}, // ŋ + {13, 15, 13, 0, 15, 25, 3648}, // Ō + {10, 12, 10, 0, 12, 15, 3673}, // ō + {13, 15, 13, 0, 15, 25, 3688}, // Ŏ + {10, 12, 10, 0, 12, 15, 3713}, // ŏ + {13, 16, 13, 0, 16, 26, 3728}, // Ő + {10, 13, 10, 0, 13, 17, 3754}, // ő + {17, 12, 17, 0, 12, 26, 3771}, // Œ + {16, 9, 16, 0, 9, 18, 3797}, // œ + {10, 16, 11, 1, 16, 20, 3815}, // Ŕ + {6, 13, 7, 1, 13, 10, 3835}, // ŕ + {10, 15, 11, 1, 12, 19, 3845}, // Ŗ + {7, 12, 7, 0, 9, 11, 3864}, // ŗ + {10, 16, 11, 1, 16, 20, 3875}, // Ř + {6, 13, 7, 1, 13, 10, 3895}, // ř + {9, 16, 9, 0, 16, 18, 3905}, // Ś + {7, 13, 8, 0, 13, 12, 3923}, // ś + {9, 16, 9, 0, 16, 18, 3935}, // Ŝ + {7, 13, 8, 0, 13, 12, 3953}, // ŝ + {9, 15, 9, 0, 12, 17, 3965}, // Ş + {7, 12, 8, 0, 9, 11, 3982}, // ş + {9, 16, 9, 0, 16, 18, 3993}, // Š + {7, 13, 8, 0, 13, 12, 4011}, // š + {10, 15, 10, 0, 12, 19, 4023}, // Ţ + {6, 15, 7, 1, 12, 12, 4042}, // ţ + {10, 16, 10, 0, 16, 20, 4054}, // Ť + {6, 13, 7, 1, 13, 10, 4074}, // ť + {10, 12, 10, 0, 12, 15, 4084}, // Ŧ + {6, 12, 7, 1, 12, 9, 4099}, // ŧ + {10, 16, 12, 1, 16, 20, 4108}, // Ũ + {8, 13, 10, 1, 13, 13, 4128}, // ũ + {10, 15, 12, 1, 15, 19, 4141}, // Ū + {8, 12, 10, 1, 12, 12, 4160}, // ū + {10, 15, 12, 1, 15, 19, 4172}, // Ŭ + {8, 12, 10, 1, 12, 12, 4191}, // ŭ + {10, 17, 12, 1, 17, 22, 4203}, // Ů + {8, 14, 10, 1, 14, 14, 4225}, // ů + {10, 16, 12, 1, 16, 20, 4239}, // Ű + {8, 13, 10, 1, 13, 13, 4259}, // ű + {10, 15, 12, 1, 12, 19, 4272}, // Ų + {8, 12, 10, 1, 9, 12, 4291}, // ų + {16, 16, 16, 0, 16, 32, 4303}, // Ŵ + {13, 13, 13, 0, 13, 22, 4335}, // ŵ + {11, 16, 10, 0, 16, 22, 4357}, // Ŷ + {9, 16, 8, 0, 13, 18, 4379}, // ŷ + {11, 15, 10, 0, 15, 21, 4397}, // Ÿ + {10, 16, 10, 0, 16, 20, 4418}, // Ź + {8, 13, 8, 0, 13, 13, 4438}, // ź + {10, 15, 10, 0, 15, 19, 4451}, // Ż + {8, 12, 8, 0, 12, 12, 4470}, // ż + {10, 16, 10, 0, 16, 20, 4482}, // Ž + {8, 13, 8, 0, 13, 13, 4502}, // ž + {6, 14, 5, 1, 14, 11, 4515}, // ſ + {6, 2, 6, 0, 12, 2, 4526}, // ̑ + {9, 16, 10, 1, 16, 18, 4528}, // Ѐ + {9, 15, 10, 1, 15, 17, 4546}, // Ё + {13, 12, 13, 0, 12, 20, 4563}, // Ђ + {8, 16, 9, 1, 16, 16, 4583}, // Ѓ + {11, 12, 11, 0, 12, 17, 4599}, // Є + {9, 12, 9, 0, 12, 14, 4616}, // Ѕ + {3, 12, 5, 1, 12, 5, 4630}, // І + {6, 15, 5, -1, 15, 12, 4635}, // Ї + {8, 12, 9, 0, 12, 12, 4647}, // Ј + {18, 13, 18, 0, 12, 30, 4659}, // Љ + {16, 12, 17, 1, 12, 24, 4689}, // Њ + {12, 12, 13, 0, 12, 18, 4713}, // Ћ + {10, 16, 11, 1, 16, 20, 4731}, // Ќ + {10, 16, 12, 1, 16, 20, 4751}, // Ѝ + {10, 15, 10, 0, 15, 19, 4771}, // Ў + {10, 15, 12, 1, 12, 19, 4790}, // Џ + {12, 12, 11, 0, 12, 18, 4809}, // А + {9, 12, 11, 1, 12, 14, 4827}, // Б + {10, 12, 11, 1, 12, 15, 4841}, // В + {8, 12, 9, 1, 12, 12, 4856}, // Г + {12, 15, 12, 0, 12, 23, 4868}, // Д + {9, 12, 10, 1, 12, 14, 4891}, // Е + {15, 12, 15, 0, 12, 23, 4905}, // Ж + {9, 12, 10, 0, 12, 14, 4928}, // З + {10, 12, 12, 1, 12, 15, 4942}, // И + {10, 15, 12, 1, 15, 19, 4957}, // Й + {10, 12, 11, 1, 12, 15, 4976}, // К + {11, 12, 12, 0, 12, 17, 4991}, // Л + {13, 12, 15, 1, 12, 20, 5008}, // М + {10, 12, 12, 1, 12, 15, 5028}, // Н + {13, 12, 13, 0, 12, 20, 5043}, // О + {10, 12, 12, 1, 12, 15, 5063}, // П + {9, 12, 10, 1, 12, 14, 5078}, // Р + {11, 13, 11, 0, 13, 18, 5092}, // С + {10, 12, 10, 0, 12, 15, 5110}, // Т + {10, 12, 10, 0, 12, 15, 5125}, // У + {14, 13, 15, 0, 13, 23, 5140}, // Ф + {11, 12, 11, 0, 12, 17, 5163}, // Х + {11, 15, 12, 1, 12, 21, 5180}, // Ц + {10, 12, 11, 0, 12, 15, 5201}, // Ч + {15, 12, 17, 1, 12, 23, 5216}, // Ш + {16, 15, 17, 1, 12, 30, 5239}, // Щ + {12, 12, 12, 0, 12, 18, 5269}, // Ъ + {13, 12, 15, 1, 12, 20, 5287}, // Ы + {9, 12, 10, 1, 12, 14, 5307}, // Ь + {10, 12, 11, 0, 12, 15, 5321}, // Э + {16, 12, 18, 1, 12, 24, 5336}, // Ю + {10, 12, 11, 0, 12, 15, 5360}, // Я + {8, 9, 9, 0, 9, 9, 5375}, // а + {9, 13, 10, 1, 13, 15, 5384}, // б + {8, 9, 9, 1, 9, 9, 5399}, // в + {6, 9, 7, 1, 9, 7, 5408}, // г + {10, 11, 10, 0, 9, 14, 5415}, // д + {9, 9, 10, 0, 9, 11, 5429}, // е + {13, 9, 13, 0, 9, 15, 5440}, // ж + {8, 9, 8, 0, 9, 9, 5455}, // з + {8, 9, 10, 1, 9, 9, 5464}, // и + {8, 12, 10, 1, 12, 12, 5473}, // й + {8, 9, 9, 1, 9, 9, 5485}, // к + {9, 9, 10, 0, 9, 11, 5494}, // л + {11, 9, 12, 1, 9, 13, 5505}, // м + {8, 9, 10, 1, 9, 9, 5518}, // н + {10, 9, 10, 0, 9, 12, 5527}, // о + {8, 9, 10, 1, 9, 9, 5539}, // п + {9, 12, 10, 1, 9, 14, 5548}, // р + {8, 9, 8, 0, 9, 9, 5562}, // с + {8, 9, 8, 0, 9, 9, 5571}, // т + {9, 12, 8, 0, 9, 14, 5580}, // у + {13, 16, 13, 0, 13, 26, 5594}, // ф + {9, 9, 9, 0, 9, 11, 5620}, // х + {9, 11, 10, 1, 9, 13, 5631}, // ц + {8, 9, 9, 0, 9, 9, 5644}, // ч + {12, 9, 14, 1, 9, 14, 5653}, // ш + {13, 11, 14, 1, 9, 18, 5667}, // щ + {10, 9, 10, 0, 9, 12, 5685}, // ъ + {11, 9, 13, 1, 9, 13, 5697}, // ы + {8, 9, 9, 1, 9, 9, 5710}, // ь + {8, 9, 8, 0, 9, 9, 5719}, // э + {12, 9, 14, 1, 9, 14, 5728}, // ю + {8, 9, 9, 0, 9, 9, 5742}, // я + {9, 13, 10, 0, 13, 15, 5751}, // ѐ + {9, 12, 10, 0, 12, 14, 5766}, // ё + {9, 16, 10, 0, 13, 18, 5780}, // ђ + {6, 13, 7, 1, 13, 10, 5798}, // ѓ + {8, 9, 8, 0, 9, 9, 5808}, // є + {7, 9, 8, 0, 9, 8, 5817}, // ѕ + {3, 13, 4, 1, 13, 5, 5825}, // і + {6, 12, 4, -1, 12, 9, 5830}, // ї + {6, 16, 4, -2, 13, 12, 5839}, // ј + {15, 9, 15, 0, 9, 17, 5851}, // љ + {13, 9, 14, 1, 9, 15, 5868}, // њ + {9, 13, 10, 0, 13, 15, 5883}, // ћ + {8, 13, 9, 1, 13, 13, 5898}, // ќ + {8, 13, 10, 1, 13, 13, 5911}, // ѝ + {9, 15, 8, 0, 12, 17, 5924}, // ў + {8, 11, 10, 1, 9, 11, 5941}, // џ + {12, 13, 12, 0, 13, 20, 5952}, // Ѣ + {10, 13, 10, 0, 13, 17, 5972}, // ѣ + {13, 12, 13, 0, 12, 20, 5989}, // Ѳ + {10, 9, 10, 0, 9, 12, 6009}, // ѳ + {14, 12, 13, 0, 12, 21, 6021}, // Ѵ + {10, 9, 10, 0, 9, 12, 6042}, // ѵ + {12, 18, 13, 1, 15, 27, 6054}, // Ҋ + {10, 15, 10, 1, 12, 19, 6081}, // ҋ + {11, 13, 11, 0, 13, 18, 6100}, // Ҍ + {9, 9, 9, 0, 9, 11, 6118}, // ҍ + {9, 12, 10, 1, 12, 14, 6129}, // Ҏ + {9, 12, 10, 1, 9, 14, 6143}, // ҏ + {8, 14, 9, 1, 14, 14, 6157}, // Ґ + {6, 11, 7, 1, 11, 9, 6171}, // ґ + {9, 12, 9, 0, 12, 14, 6180}, // Ғ + {7, 9, 7, 0, 9, 8, 6194}, // ғ + {9, 15, 11, 1, 12, 17, 6202}, // Ҕ + {8, 12, 9, 1, 9, 12, 6219}, // ҕ + {16, 15, 16, 0, 12, 30, 6231}, // Җ + {14, 11, 13, 0, 9, 20, 6261}, // җ + {9, 15, 10, 0, 12, 17, 6281}, // Ҙ + {8, 12, 8, 0, 9, 12, 6298}, // ҙ + {11, 15, 11, 1, 12, 21, 6310}, // Қ + {9, 12, 9, 1, 9, 14, 6331}, // қ + {11, 12, 12, 1, 12, 17, 6345}, // Ҝ + {10, 9, 11, 1, 9, 12, 6362}, // ҝ + {11, 12, 11, 0, 12, 17, 6374}, // Ҟ + {10, 9, 9, 0, 9, 12, 6391}, // ҟ + {13, 12, 13, 0, 12, 20, 6403}, // Ҡ + {11, 9, 10, 0, 9, 13, 6423}, // ҡ + {11, 15, 12, 1, 12, 21, 6436}, // Ң + {9, 11, 10, 1, 9, 13, 6457}, // ң + {13, 12, 14, 1, 12, 20, 6470}, // Ҥ + {10, 9, 11, 1, 9, 12, 6490}, // ҥ + {16, 15, 18, 1, 12, 30, 6502}, // Ҧ + {14, 12, 15, 1, 9, 21, 6532}, // ҧ + {13, 15, 13, 0, 12, 25, 6553}, // Ҩ + {10, 11, 10, 0, 9, 14, 6578}, // ҩ + {11, 16, 11, 0, 13, 22, 6592}, // Ҫ + {8, 12, 8, 0, 9, 12, 6614}, // ҫ + {10, 15, 10, 0, 12, 19, 6626}, // Ҭ + {8, 11, 8, 0, 9, 11, 6645}, // ҭ + {11, 12, 10, 0, 12, 17, 6656}, // Ү + {9, 12, 9, 0, 9, 14, 6673}, // ү + {11, 12, 10, 0, 12, 17, 6687}, // Ұ + {9, 12, 9, 0, 9, 14, 6704}, // ұ + {11, 15, 11, 0, 12, 21, 6718}, // Ҳ + {9, 11, 9, 0, 9, 13, 6739}, // ҳ + {15, 15, 14, 0, 12, 29, 6752}, // Ҵ + {11, 11, 11, 0, 9, 16, 6781}, // ҵ + {11, 16, 11, 0, 12, 22, 6797}, // Ҷ + {9, 11, 9, 0, 9, 13, 6819}, // ҷ + {10, 12, 11, 0, 12, 15, 6832}, // Ҹ + {8, 9, 9, 0, 9, 9, 6847}, // ҹ + {9, 12, 11, 1, 12, 14, 6856}, // Һ + {8, 13, 10, 1, 13, 13, 6870}, // һ + {14, 12, 14, 0, 12, 21, 6883}, // Ҽ + {11, 9, 11, 0, 9, 13, 6904}, // ҽ + {14, 15, 14, 0, 12, 27, 6917}, // Ҿ + {11, 11, 11, 0, 9, 16, 6944}, // ҿ + {3, 12, 5, 1, 12, 5, 6960}, // Ӏ + {15, 15, 15, 0, 15, 29, 6965}, // Ӂ + {13, 12, 13, 0, 12, 20, 6994}, // ӂ + {9, 15, 11, 1, 12, 17, 7014}, // Ӄ + {8, 12, 9, 1, 9, 12, 7031}, // ӄ + {13, 15, 12, 0, 12, 25, 7043}, // Ӆ + {11, 12, 10, 0, 9, 17, 7068}, // ӆ + {10, 15, 12, 1, 12, 19, 7085}, // Ӈ + {8, 12, 10, 1, 9, 12, 7104}, // ӈ + {12, 15, 12, 1, 12, 23, 7116}, // Ӊ + {10, 12, 10, 1, 9, 15, 7139}, // ӊ + {9, 15, 11, 1, 12, 17, 7154}, // Ӌ + {8, 11, 9, 0, 9, 11, 7171}, // ӌ + {15, 15, 15, 1, 12, 29, 7182}, // Ӎ + {12, 12, 13, 1, 9, 18, 7211}, // ӎ + {3, 12, 5, 1, 12, 5, 7229}, // ӏ + {12, 15, 11, 0, 15, 23, 7234}, // Ӑ + {8, 12, 9, 0, 12, 12, 7257}, // ӑ + {12, 15, 11, 0, 15, 23, 7269}, // Ӓ + {8, 12, 9, 0, 12, 12, 7292}, // ӓ + {16, 12, 16, 0, 12, 24, 7304}, // Ӕ + {14, 9, 15, 0, 9, 16, 7328}, // ӕ + {9, 15, 10, 1, 15, 17, 7344}, // Ӗ + {9, 12, 10, 0, 12, 14, 7361}, // ӗ + {11, 12, 12, 0, 12, 17, 7375}, // Ә + {9, 9, 10, 0, 9, 11, 7392}, // ә + {11, 15, 12, 0, 15, 21, 7403}, // Ӛ + {9, 12, 10, 0, 12, 14, 7424}, // ӛ + {15, 15, 15, 0, 15, 29, 7438}, // Ӝ + {13, 12, 13, 0, 12, 20, 7467}, // ӝ + {9, 15, 10, 0, 15, 17, 7487}, // Ӟ + {8, 12, 8, 0, 12, 12, 7504}, // ӟ + {9, 12, 10, 0, 12, 14, 7516}, // Ӡ + {9, 12, 8, -1, 9, 14, 7530}, // ӡ + {10, 15, 12, 1, 15, 19, 7544}, // Ӣ + {8, 12, 10, 1, 12, 12, 7563}, // ӣ + {10, 15, 12, 1, 15, 19, 7575}, // Ӥ + {8, 12, 10, 1, 12, 12, 7594}, // ӥ + {13, 15, 13, 0, 15, 25, 7606}, // Ӧ + {10, 12, 10, 0, 12, 15, 7631}, // ӧ + {13, 12, 13, 0, 12, 20, 7646}, // Ө + {10, 9, 10, 0, 9, 12, 7666}, // ө + {13, 15, 13, 0, 15, 25, 7678}, // Ӫ + {10, 12, 10, 0, 12, 15, 7703}, // ӫ + {10, 15, 11, 0, 15, 19, 7718}, // Ӭ + {8, 12, 8, 0, 12, 12, 7737}, // ӭ + {10, 15, 10, 0, 15, 19, 7749}, // Ӯ + {9, 15, 8, 0, 12, 17, 7768}, // ӯ + {10, 15, 10, 0, 15, 19, 7785}, // Ӱ + {9, 15, 8, 0, 12, 17, 7804}, // ӱ + {10, 16, 10, 0, 16, 20, 7821}, // Ӳ + {9, 16, 8, 0, 13, 18, 7841}, // ӳ + {10, 15, 11, 0, 15, 19, 7859}, // Ӵ + {8, 12, 9, 0, 12, 12, 7878}, // ӵ + {8, 15, 9, 1, 12, 15, 7890}, // Ӷ + {6, 11, 7, 1, 9, 9, 7905}, // ӷ + {13, 15, 15, 1, 15, 25, 7914}, // Ӹ + {11, 12, 13, 1, 12, 17, 7939}, // ӹ + {10, 1, 8, -1, 5, 2, 7956}, // – + {18, 1, 17, -1, 5, 3, 7958}, // — + {18, 1, 17, -1, 5, 3, 7961}, // ― + {3, 6, 4, 0, 13, 3, 7964}, // ‘ + {3, 6, 4, 0, 13, 3, 7967}, // ’ + {3, 4, 4, 0, 2, 2, 7970}, // ‚ + {6, 6, 6, 0, 13, 5, 7972}, // “ + {6, 6, 6, 0, 13, 5, 7977}, // ” + {7, 4, 7, 0, 2, 4, 7982}, // „ + {8, 14, 8, 0, 12, 14, 7986}, // † + {8, 14, 8, 0, 12, 14, 8000}, // ‡ + {6, 4, 6, 0, 8, 3, 8014}, // • + {15, 2, 17, 1, 2, 4, 8017}, // … + {21, 12, 21, 0, 12, 32, 8021}, // ‰ + {5, 7, 5, 0, 8, 5, 8053}, // ‹ + {5, 7, 5, 0, 8, 5, 8058}, // › + {9, 12, 3, -3, 12, 14, 8063}, // ⁄ + {10, 12, 10, 0, 12, 15, 8077}, // € + {9, 12, 10, 0, 12, 14, 8092}, // ₮ + {9, 14, 10, 0, 13, 16, 8106}, // ₴ + {9, 12, 10, 1, 12, 14, 8122}, // ₹ + {10, 13, 10, 0, 13, 17, 8136}, // ∂ + {11, 12, 11, 0, 12, 17, 8153}, // ∆ + {10, 13, 12, 1, 11, 17, 8170}, // ∏ + {9, 14, 9, 0, 12, 16, 8187}, // ∑ + {9, 1, 10, 0, 5, 2, 8203}, // − + {9, 12, 3, -3, 12, 14, 8205}, // ∕ + {4, 2, 4, 0, 6, 1, 8219}, // ∙ + {10, 13, 10, 0, 13, 17, 8220}, // √ + {10, 5, 10, 0, 7, 7, 8237}, // ∞ + {7, 16, 6, 0, 13, 14, 8244}, // ∫ + {9, 6, 10, 0, 8, 7, 8258}, // ≈ + {9, 8, 10, 0, 9, 9, 8265}, // ≠ + {9, 9, 10, 0, 9, 11, 8274}, // ≤ + {9, 9, 10, 0, 9, 11, 8285}, // ≥ }; static const EpdUnicodeInterval pixelarial14Intervals[] = { - {0x20, 0x7E, 0x0}, - {0x2018, 0x2019, 0x5F}, - {0x201C, 0x201D, 0x61}, + {0x0, 0x0, 0x0}, {0x8, 0x9, 0x1}, {0xD, 0xD, 0x3}, {0x1D, 0x1D, 0x4}, + {0x20, 0x7E, 0x5}, {0xA0, 0xFF, 0x64}, {0x100, 0x17F, 0xC4}, {0x311, 0x311, 0x144}, + {0x400, 0x45F, 0x145}, {0x462, 0x463, 0x1A5}, {0x472, 0x475, 0x1A7}, {0x48A, 0x4F9, 0x1AB}, + {0x2013, 0x2015, 0x21B}, {0x2018, 0x201A, 0x21E}, {0x201C, 0x201E, 0x221}, {0x2020, 0x2022, 0x224}, + {0x2026, 0x2026, 0x227}, {0x2030, 0x2030, 0x228}, {0x2039, 0x203A, 0x229}, {0x2044, 0x2044, 0x22B}, + {0x20AC, 0x20AC, 0x22C}, {0x20AE, 0x20AE, 0x22D}, {0x20B4, 0x20B4, 0x22E}, {0x20B9, 0x20B9, 0x22F}, + {0x2202, 0x2202, 0x230}, {0x2206, 0x2206, 0x231}, {0x220F, 0x220F, 0x232}, {0x2211, 0x2212, 0x233}, + {0x2215, 0x2215, 0x235}, {0x2219, 0x221A, 0x236}, {0x221E, 0x221E, 0x238}, {0x222B, 0x222B, 0x239}, + {0x2248, 0x2248, 0x23A}, {0x2260, 0x2260, 0x23B}, {0x2264, 0x2265, 0x23C}, }; static const EpdFontData pixelarial14 = { - pixelarial14Bitmaps, pixelarial14Glyphs, pixelarial14Intervals, 3, 17, 13, -4, false, -}; + pixelarial14Bitmaps, pixelarial14Glyphs, pixelarial14Intervals, 35, 17, 13, -4, false, +}; \ No newline at end of file diff --git a/src/config.h b/src/config.h index a3784460..58a73054 100644 --- a/src/config.h +++ b/src/config.h @@ -26,4 +26,4 @@ * "./lib/EpdFont/builtinFonts/pixelarial14.h", * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' */ -#define SMALL_FONT_ID (-139796914) +#define SMALL_FONT_ID 1482513144 From fcfa10bb1f41bf6efafc8e85c0de457ba4b8f80c Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 19:02:21 +1100 Subject: [PATCH 17/33] Cut release 0.8.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 0f923803..a4bdcd19 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.7.0 +crosspoint_version = 0.8.0 default_envs = default [base] From 246afae6efef96287debf595505f57d67cb47d93 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 21:16:41 +1100 Subject: [PATCH 18/33] Start power off sequence as soon as hold duration for the power button is reached (#93) ## Summary * Swap from `wasReleased` to `isPressed` when checking power button duration * In practice it makes the power down experience feel a lot snappier * Remove the unnecessary 1000ms delay when powering off ## Additional Context * A little discussion in here: https://github.com/daveallie/crosspoint-reader/discussions/53#discussioncomment-15309707 --- src/main.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5dfc25ba..0d217812 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -119,14 +119,12 @@ void enterDeepSleep() { exitActivity(); enterNewActivity(new SleepActivity(renderer, inputManager)); - Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); - delay(1000); // Allow Serial buffer to empty and display to update - - // Enable Wakeup on LOW (button press) - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - einkDisplay.deepSleep(); + Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); + // Ensure that the power button has been released to avoid immediately turning back on if you're holding it + waitForPowerRelease(); // Enter Deep Sleep esp_deep_sleep_start(); } @@ -231,7 +229,7 @@ void loop() { return; } - if (inputManager.wasReleased(InputManager::BTN_POWER) && + if (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) { enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start From 77c655fcf5f3af9969f76718ed04f60dfd106474 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 21:17:00 +1100 Subject: [PATCH 19/33] Give activities names and log when entering and exiting them (#92) ## Summary * Give activities name and log when entering and exiting them * Clearer logs when attempting to debug, knowing where users are coming from/going to helps --- src/activities/Activity.h | 11 ++- src/activities/ActivityWithSubactivity.cpp | 5 +- src/activities/ActivityWithSubactivity.h | 4 +- src/activities/boot_sleep/BootActivity.cpp | 2 + src/activities/boot_sleep/BootActivity.h | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 6 ++ src/activities/boot_sleep/SleepActivity.h | 3 +- src/activities/home/HomeActivity.cpp | 4 + src/activities/home/HomeActivity.h | 2 +- .../network/CrossPointWebServerActivity.cpp | 91 ++++++++----------- .../network/CrossPointWebServerActivity.h | 8 +- .../network/WifiSelectionActivity.cpp | 6 +- .../network/WifiSelectionActivity.h | 2 +- src/activities/reader/EpubReaderActivity.cpp | 22 +++-- src/activities/reader/EpubReaderActivity.h | 7 +- .../EpubReaderChapterSelectionActivity.cpp | 4 + .../EpubReaderChapterSelectionActivity.h | 2 +- .../reader/FileSelectionActivity.cpp | 4 + src/activities/reader/FileSelectionActivity.h | 2 +- src/activities/reader/ReaderActivity.cpp | 2 + src/activities/reader/ReaderActivity.h | 2 +- src/activities/settings/SettingsActivity.cpp | 6 +- src/activities/settings/SettingsActivity.h | 4 +- .../util/FullScreenMessageActivity.cpp | 2 + .../util/FullScreenMessageActivity.h | 5 +- src/activities/util/KeyboardEntryActivity.cpp | 11 +-- src/activities/util/KeyboardEntryActivity.h | 8 +- 27 files changed, 123 insertions(+), 104 deletions(-) diff --git a/src/activities/Activity.h b/src/activities/Activity.h index dfe67143..90a2eb10 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -1,19 +1,22 @@ #pragma once #include +#include + class GfxRenderer; class Activity { protected: + std::string name; GfxRenderer& renderer; InputManager& inputManager; public: - explicit Activity(GfxRenderer& renderer, InputManager& inputManager) - : renderer(renderer), inputManager(inputManager) {} + explicit Activity(std::string name, GfxRenderer& renderer, InputManager& inputManager) + : name(std::move(name)), renderer(renderer), inputManager(inputManager) {} virtual ~Activity() = default; - virtual void onEnter() {} - virtual void onExit() {} + virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); } + virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void loop() {} virtual bool skipLoopDelay() { return false; } }; diff --git a/src/activities/ActivityWithSubactivity.cpp b/src/activities/ActivityWithSubactivity.cpp index 56dccd98..61b1fc1e 100644 --- a/src/activities/ActivityWithSubactivity.cpp +++ b/src/activities/ActivityWithSubactivity.cpp @@ -18,4 +18,7 @@ void ActivityWithSubactivity::loop() { } } -void ActivityWithSubactivity::onExit() { exitActivity(); } +void ActivityWithSubactivity::onExit() { + Activity::onExit(); + exitActivity(); +} diff --git a/src/activities/ActivityWithSubactivity.h b/src/activities/ActivityWithSubactivity.h index b3a68730..af559876 100644 --- a/src/activities/ActivityWithSubactivity.h +++ b/src/activities/ActivityWithSubactivity.h @@ -10,8 +10,8 @@ class ActivityWithSubactivity : public Activity { void enterNewActivity(Activity* activity); public: - explicit ActivityWithSubactivity(GfxRenderer& renderer, InputManager& inputManager) - : Activity(renderer, inputManager) {} + explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, InputManager& inputManager) + : Activity(std::move(name), renderer, inputManager) {} void loop() override; void onExit() override; }; diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index a76cb7c9..78a12482 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -6,6 +6,8 @@ #include "images/CrossLarge.h" void BootActivity::onEnter() { + Activity::onEnter(); + const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageHeight = GfxRenderer::getScreenHeight(); diff --git a/src/activities/boot_sleep/BootActivity.h b/src/activities/boot_sleep/BootActivity.h index dd647ceb..a14d0c70 100644 --- a/src/activities/boot_sleep/BootActivity.h +++ b/src/activities/boot_sleep/BootActivity.h @@ -3,6 +3,6 @@ class BootActivity final : public Activity { public: - explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} + explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity("Boot", renderer, inputManager) {} void onEnter() override; }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4200c4e9..ca72aeb1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -12,6 +12,7 @@ #include "images/CrossLarge.h" void SleepActivity::onEnter() { + Activity::onEnter(); renderPopup("Entering Sleep..."); if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { @@ -170,11 +171,16 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { } void SleepActivity::renderCoverSleepScreen() const { + if (APP_STATE.openEpubPath.empty()) { + return renderDefaultSleepScreen(); + } + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastEpub.load()) { Serial.println("[SLP] Failed to load last epub"); return renderDefaultSleepScreen(); } + if (!lastEpub.generateCoverBmp()) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 21121994..774fd25b 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -5,7 +5,8 @@ class Bitmap; class SleepActivity final : public Activity { public: - explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} + explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) + : Activity("Sleep", renderer, inputManager) {} void onEnter() override; private: diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 82d57cc8..9cfa1851 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -15,6 +15,8 @@ void HomeActivity::taskTrampoline(void* param) { } void HomeActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); selectorIndex = 0; @@ -31,6 +33,8 @@ void HomeActivity::onEnter() { } void HomeActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 5dd26ec5..943a4665 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -23,7 +23,7 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onReaderOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen) - : Activity(renderer, inputManager), + : Activity("Home", renderer, inputManager), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen) {} diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index bb0f39a2..d02411fd 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -11,7 +11,8 @@ void CrossPointWebServerActivity::taskTrampoline(void* param) { } void CrossPointWebServerActivity::onEnter() { - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis()); + ActivityWithSubactivity::onEnter(); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); renderingMutex = xSemaphoreCreateMutex(); @@ -36,13 +37,13 @@ void CrossPointWebServerActivity::onEnter() { // Launch WiFi selection subactivity Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager, - [this](bool connected) { onWifiSelectionComplete(connected); })); - wifiSelection->onEnter(); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); } void CrossPointWebServerActivity::onExit() { - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis()); + ActivityWithSubactivity::onExit(); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); state = WebServerActivityState::SHUTTING_DOWN; @@ -50,14 +51,6 @@ void CrossPointWebServerActivity::onExit() { // Stop the web server first (before disconnecting WiFi) stopWebServer(); - // Exit WiFi selection subactivity if still active - if (wifiSelection) { - Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis()); - wifiSelection->onExit(); - wifiSelection.reset(); - Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis()); - } - // CRITICAL: Wait for LWIP stack to flush any pending packets Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); delay(500); @@ -92,20 +85,17 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis()); } -void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) { +void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); if (connected) { // Get connection info before exiting subactivity - connectedIP = wifiSelection->getConnectedIP(); + connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); - // Exit the wifi selection subactivity - wifiSelection->onExit(); - wifiSelection.reset(); + exitActivity(); // Start the web server startWebServer(); @@ -150,47 +140,40 @@ void CrossPointWebServerActivity::stopWebServer() { } void CrossPointWebServerActivity::loop() { + if (subActivity) { + // Forward loop to subactivity + subActivity->loop(); + return; + } + // Handle different states - switch (state) { - case WebServerActivityState::WIFI_SELECTION: - // Forward loop to WiFi selection subactivity - if (wifiSelection) { - wifiSelection->loop(); - } - break; + if (state == WebServerActivityState::SERVER_RUNNING) { + // Handle web server requests - call handleClient multiple times per loop + // to improve responsiveness and upload throughput + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; - case WebServerActivityState::SERVER_RUNNING: - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput - if (webServer && webServer->isRunning()) { - unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; - - // Log if there's a significant gap between handleClient calls (>100ms) - if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { - Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), - timeSinceLastHandleClient); - } - - // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data - // in chunks and each handleClient() call processes incoming data - constexpr int HANDLE_CLIENT_ITERATIONS = 10; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { - webServer->handleClient(); - } - lastHandleClientTime = millis(); + // Log if there's a significant gap between handleClient calls (>100ms) + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), + timeSinceLastHandleClient); } - // Handle exit on Back button - if (inputManager.wasPressed(InputManager::BTN_BACK)) { - onGoBack(); - return; + // Call handleClient multiple times to process pending requests faster + // This is critical for upload performance - HTTP file uploads send data + // in chunks and each handleClient() call processes incoming data + constexpr int HANDLE_CLIENT_ITERATIONS = 10; + for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); } - break; + lastHandleClientTime = millis(); + } - case WebServerActivityState::SHUTTING_DOWN: - // Do nothing - waiting for cleanup - break; + // Handle exit on Back button + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onGoBack(); + return; + } } } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index ad41dcd7..76d19f1c 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -9,6 +9,7 @@ #include "../Activity.h" #include "WifiSelectionActivity.h" +#include "activities/ActivityWithSubactivity.h" #include "server/CrossPointWebServer.h" // Web server activity states @@ -26,16 +27,13 @@ enum class WebServerActivityState { * - Handles client requests in its loop() function * - Cleans up the server and shuts down WiFi on exit */ -class CrossPointWebServerActivity final : public Activity { +class CrossPointWebServerActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; const std::function onGoBack; - // WiFi selection subactivity - std::unique_ptr wifiSelection; - // Web server - owned by this activity std::unique_ptr webServer; @@ -58,7 +56,7 @@ class CrossPointWebServerActivity final : public Activity { public: explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoBack) - : Activity(renderer, inputManager), onGoBack(onGoBack) {} + : ActivityWithSubactivity("CrossPointWebServer", renderer, inputManager), onGoBack(onGoBack) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index d6c3b1ec..7f202fe3 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -14,6 +14,8 @@ void WifiSelectionActivity::taskTrampoline(void* param) { } void WifiSelectionActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); // Load saved WiFi credentials @@ -47,7 +49,8 @@ void WifiSelectionActivity::onEnter() { } void WifiSelectionActivity::onExit() { - Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit START ==========\n", millis()); + Activity::onExit(); + Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); // Stop any ongoing WiFi scan @@ -78,7 +81,6 @@ void WifiSelectionActivity::onExit() { Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis()); Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); - Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit COMPLETE ==========\n", millis()); } void WifiSelectionActivity::startWifiScan() { diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index e7b12ae2..a009de1c 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -98,7 +98,7 @@ class WifiSelectionActivity final : public Activity { public: explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onComplete) - : Activity(renderer, inputManager), onComplete(onComplete) {} + : Activity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 9635952e..84abc49c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -26,6 +26,8 @@ void EpubReaderActivity::taskTrampoline(void* param) { } void EpubReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + if (!epub) { return; } @@ -61,6 +63,8 @@ void EpubReaderActivity::onEnter() { } void EpubReaderActivity::onExit() { + ActivityWithSubactivity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -75,8 +79,8 @@ void EpubReaderActivity::onExit() { void EpubReaderActivity::loop() { // Pass input responsibility to sub activity if exists - if (subAcitivity) { - subAcitivity->loop(); + if (subActivity) { + subActivity->loop(); return; } @@ -84,11 +88,11 @@ void EpubReaderActivity::loop() { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); - subAcitivity.reset(new EpubReaderChapterSelectionActivity( + exitActivity(); + enterNewActivity(new EpubReaderChapterSelectionActivity( this->renderer, this->inputManager, epub, currentSpineIndex, [this] { - subAcitivity->onExit(); - subAcitivity.reset(); + exitActivity(); updateRequired = true; }, [this](const int newSpineIndex) { @@ -97,11 +101,9 @@ void EpubReaderActivity::loop() { nextPageNumber = 0; section.reset(); } - subAcitivity->onExit(); - subAcitivity.reset(); + exitActivity(); updateRequired = true; })); - subAcitivity->onEnter(); xSemaphoreGive(renderingMutex); } @@ -330,8 +332,8 @@ void EpubReaderActivity::renderStatusBar() const { constexpr auto textY = 776; // Calculate progress in book - float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; - uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); // Right aligned text for progress counter const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8eeddc1b..4edbabc2 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,14 +5,13 @@ #include #include -#include "../Activity.h" +#include "activities/ActivityWithSubactivity.h" -class EpubReaderActivity final : public Activity { +class EpubReaderActivity final : public ActivityWithSubactivity { std::shared_ptr epub; std::unique_ptr
section = nullptr; TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - std::unique_ptr subAcitivity = nullptr; int currentSpineIndex = 0; int nextPageNumber = 0; int pagesUntilFullRefresh = 0; @@ -28,7 +27,7 @@ class EpubReaderActivity final : public Activity { public: explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr epub, const std::function& onGoBack) - : Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} + : ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 9af556ba..07fd456d 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -16,6 +16,8 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { } void EpubReaderChapterSelectionActivity::onEnter() { + Activity::onEnter(); + if (!epub) { return; } @@ -34,6 +36,8 @@ void EpubReaderChapterSelectionActivity::onEnter() { } void EpubReaderChapterSelectionActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index b25ef6fb..8c1adef8 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -27,7 +27,7 @@ class EpubReaderChapterSelectionActivity final : public Activity { const std::shared_ptr& epub, const int currentSpineIndex, const std::function& onGoBack, const std::function& onSelectSpineIndex) - : Activity(renderer, inputManager), + : Activity("EpubReaderChapterSelection", renderer, inputManager), epub(epub), currentSpineIndex(currentSpineIndex), onGoBack(onGoBack), diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index d8aef3c7..fc39a8c2 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -48,6 +48,8 @@ void FileSelectionActivity::loadFiles() { } void FileSelectionActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); basepath = "/"; @@ -66,6 +68,8 @@ void FileSelectionActivity::onEnter() { } void FileSelectionActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index ff1a9223..2a8f8ae1 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -28,7 +28,7 @@ class FileSelectionActivity final : public Activity { explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onSelect, const std::function& onGoHome) - : Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} + : Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 93389fe7..d888fb6e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -50,6 +50,8 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { } void ReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + if (initialEpubPath.empty()) { onGoToFileSelection(); return; diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index a68cd89d..e566d6d3 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -17,7 +17,7 @@ class ReaderActivity final : public ActivityWithSubactivity { public: explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath, const std::function& onGoBack) - : ActivityWithSubactivity(renderer, inputManager), + : ActivityWithSubactivity("Reader", renderer, inputManager), initialEpubPath(std::move(initialEpubPath)), onGoBack(onGoBack) {} void onEnter() override; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 29f68762..d38d85c7 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -21,6 +21,8 @@ void SettingsActivity::taskTrampoline(void* param) { } void SettingsActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); // Reset selection to first item @@ -38,6 +40,8 @@ void SettingsActivity::onEnter() { } void SettingsActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -76,7 +80,7 @@ void SettingsActivity::loop() { } } -void SettingsActivity::toggleCurrentSetting() { +void SettingsActivity::toggleCurrentSetting() const { // Validate index if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 333f467c..6fe5db1f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -32,11 +32,11 @@ class SettingsActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void toggleCurrentSetting(); + void toggleCurrentSetting() const; public: explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Activity(renderer, inputManager), onGoHome(onGoHome) {} + : Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/util/FullScreenMessageActivity.cpp b/src/activities/util/FullScreenMessageActivity.cpp index a56952fc..cf84cc5c 100644 --- a/src/activities/util/FullScreenMessageActivity.cpp +++ b/src/activities/util/FullScreenMessageActivity.cpp @@ -5,6 +5,8 @@ #include "config.h" void FullScreenMessageActivity::onEnter() { + Activity::onEnter(); + const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (GfxRenderer::getScreenHeight() - height) / 2; diff --git a/src/activities/util/FullScreenMessageActivity.h b/src/activities/util/FullScreenMessageActivity.h index 3180ddbf..8c6e30e5 100644 --- a/src/activities/util/FullScreenMessageActivity.h +++ b/src/activities/util/FullScreenMessageActivity.h @@ -16,6 +16,9 @@ class FullScreenMessageActivity final : public Activity { explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text, const EpdFontStyle style = REGULAR, const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) - : Activity(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {} + : Activity("FullScreenMessage", renderer, inputManager), + text(std::move(text)), + style(style), + refreshMode(refreshMode) {} void onEnter() override; }; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 68fc0792..b4ed01ca 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -12,11 +12,6 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = { const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", "ZXCVBNM<>?", "^ _____ 0 && text.length() > maxLength) { @@ -37,15 +32,13 @@ void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string } void KeyboardEntryActivity::onEnter() { + Activity::onEnter(); + // Reset state when entering the activity complete = false; cancelled = false; } -void KeyboardEntryActivity::onExit() { - // Clean up if needed -} - void KeyboardEntryActivity::loop() { handleInput(); render(10); diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index af6d9b46..3b5b8063 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -34,7 +34,12 @@ class KeyboardEntryActivity : public Activity { * @param isPassword If true, display asterisks instead of actual characters */ KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", - const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false); + const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false) + : Activity("KeyboardEntry", renderer, inputManager), + title(title), + text(initialText), + maxLength(maxLength), + isPassword(isPassword) {} /** * Handle button input. Call this in your main loop. @@ -85,7 +90,6 @@ class KeyboardEntryActivity : public Activity { // Activity overrides void onEnter() override; - void onExit() override; void loop() override; private: From b39ce22e5427a528459398babc2a7ef280e456e0 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 00:31:25 +1100 Subject: [PATCH 20/33] Cleanup of activities --- src/activities/Activity.h | 5 +- src/activities/home/HomeActivity.cpp | 5 +- .../network/CrossPointWebServerActivity.cpp | 2 + .../network/CrossPointWebServerActivity.h | 4 +- .../network/WifiSelectionActivity.cpp | 70 +++++++++++-------- .../network/WifiSelectionActivity.h | 10 +-- src/activities/reader/EpubReaderActivity.cpp | 1 + .../EpubReaderChapterSelectionActivity.cpp | 1 + .../reader/FileSelectionActivity.cpp | 1 + src/activities/settings/SettingsActivity.cpp | 1 + .../CrossPointWebServer.cpp | 0 .../server => network}/CrossPointWebServer.h | 2 - src/{ => network}/html/FilesPageFooter.html | 0 src/{ => network}/html/FilesPageHeader.html | 0 src/{ => network}/html/HomePage.html | 0 15 files changed, 57 insertions(+), 45 deletions(-) rename src/{activities/network/server => network}/CrossPointWebServer.cpp (100%) rename src/{activities/network/server => network}/CrossPointWebServer.h (96%) rename src/{ => network}/html/FilesPageFooter.html (100%) rename src/{ => network}/html/FilesPageHeader.html (100%) rename src/{ => network}/html/HomePage.html (100%) diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 90a2eb10..3a61db69 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -1,8 +1,11 @@ #pragma once -#include +#include + +#include #include +class InputManager; class GfxRenderer; class Activity { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 9cfa1851..bbda1307 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,6 +1,7 @@ #include "HomeActivity.h" #include +#include #include #include "config.h" @@ -83,8 +84,8 @@ void HomeActivity::displayTaskLoop() { void HomeActivity::render() const { renderer.clearScreen(); - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); // Draw selection diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index d02411fd..5bf571a9 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -1,8 +1,10 @@ #include "CrossPointWebServerActivity.h" #include +#include #include +#include "WifiSelectionActivity.h" #include "config.h" void CrossPointWebServerActivity::taskTrampoline(void* param) { diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 76d19f1c..6889f6eb 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -7,10 +7,8 @@ #include #include -#include "../Activity.h" -#include "WifiSelectionActivity.h" #include "activities/ActivityWithSubactivity.h" -#include "server/CrossPointWebServer.h" +#include "network/CrossPointWebServer.h" // Web server activity states enum class WebServerActivityState { diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 7f202fe3..c18e0f57 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -6,6 +6,7 @@ #include #include "WifiCredentialStore.h" +#include "activities/util/KeyboardEntryActivity.h" #include "config.h" void WifiSelectionActivity::taskTrampoline(void* param) { @@ -18,8 +19,10 @@ void WifiSelectionActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - // Load saved WiFi credentials + // Load saved WiFi credentials - SD card operations need lock as we use SPI for both + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.loadFromFile(); + xSemaphoreGive(renderingMutex); // Reset state selectedNetworkIndex = 0; @@ -32,7 +35,6 @@ void WifiSelectionActivity::onEnter() { usedSavedPassword = false; savePromptSelection = 0; forgetPromptSelection = 0; - keyboard.reset(); // Trigger first update to show scanning message updateRequired = true; @@ -98,7 +100,7 @@ void WifiSelectionActivity::startWifiScan() { } void WifiSelectionActivity::processWifiScanResults() { - int16_t scanResult = WiFi.scanComplete(); + const int16_t scanResult = WiFi.scanComplete(); if (scanResult == WIFI_SCAN_RUNNING) { // Scan still in progress @@ -117,7 +119,7 @@ void WifiSelectionActivity::processWifiScanResults() { for (int i = 0; i < scanResult; i++) { std::string ssid = WiFi.SSID(i).c_str(); - int32_t rssi = WiFi.RSSI(i); + const int32_t rssi = WiFi.RSSI(i); // Skip hidden networks (empty SSID) if (ssid.empty()) { @@ -154,7 +156,7 @@ void WifiSelectionActivity::processWifiScanResults() { updateRequired = true; } -void WifiSelectionActivity::selectNetwork(int index) { +void WifiSelectionActivity::selectNetwork(const int index) { if (index < 0 || index >= static_cast(networks.size())) { return; } @@ -180,11 +182,11 @@ void WifiSelectionActivity::selectNetwork(int index) { if (selectedRequiresPassword) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; - keyboard.reset(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", - "", // No initial text - 64, // Max password length - false // Show password by default (hard keyboard to use) - )); + enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", + "", // No initial text + 64, // Max password length + false // Show password by default (hard keyboard to use) + )); updateRequired = true; } else { // Connect directly for open networks @@ -202,8 +204,8 @@ void WifiSelectionActivity::attemptConnection() { WiFi.mode(WIFI_STA); // Get password from keyboard if we just entered it - if (keyboard && !usedSavedPassword) { - enteredPassword = keyboard->getText(); + if (subActivity && !usedSavedPassword) { + enteredPassword = static_cast(subActivity.get())->getText(); } if (selectedRequiresPassword && !enteredPassword.empty()) { @@ -218,7 +220,7 @@ void WifiSelectionActivity::checkConnectionStatus() { return; } - wl_status_t status = WiFi.status(); + const wl_status_t status = WiFi.status(); if (status == WL_CONNECTED) { // Successfully connected @@ -275,7 +277,8 @@ void WifiSelectionActivity::loop() { } // Handle password entry state - if (state == WifiSelectionState::PASSWORD_ENTRY && keyboard) { + if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) { + const auto keyboard = static_cast(subActivity.get()); keyboard->handleInput(); if (keyboard->isComplete()) { @@ -285,7 +288,7 @@ void WifiSelectionActivity::loop() { if (keyboard->isCancelled()) { state = WifiSelectionState::NETWORK_LIST; - keyboard.reset(); + exitActivity(); updateRequired = true; return; } @@ -309,7 +312,9 @@ void WifiSelectionActivity::loop() { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (savePromptSelection == 0) { // User chose "Yes" - save the password + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.addCredential(selectedSSID, enteredPassword); + xSemaphoreGive(renderingMutex); } // Complete - parent will start web server onComplete(true); @@ -335,7 +340,9 @@ void WifiSelectionActivity::loop() { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (forgetPromptSelection == 0) { // User chose "Yes" - forget the network + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.removeCredential(selectedSSID); + xSemaphoreGive(renderingMutex); // Update the network list to reflect the change const auto network = find_if(networks.begin(), networks.end(), [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; }); @@ -410,15 +417,18 @@ void WifiSelectionActivity::loop() { } } -std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) const { +std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi) const { // Convert RSSI to signal bars representation if (rssi >= -50) { return "||||"; // Excellent - } else if (rssi >= -60) { + } + if (rssi >= -60) { return "||| "; // Good - } else if (rssi >= -70) { + } + if (rssi >= -70) { return "|| "; // Fair - } else if (rssi >= -80) { + } + if (rssi >= -80) { return "| "; // Weak } return " "; // Very weak @@ -484,8 +494,8 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR); } else { // Calculate how many networks we can display - const int startY = 60; - const int lineHeight = 25; + constexpr int startY = 60; + constexpr int lineHeight = 25; const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight; // Calculate scroll offset to keep selected item visible @@ -557,8 +567,8 @@ void WifiSelectionActivity::renderPasswordEntry() const { renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); // Draw keyboard - if (keyboard) { - keyboard->render(58); + if (subActivity) { + static_cast(subActivity.get())->render(58); } } @@ -593,7 +603,7 @@ void WifiSelectionActivity::renderConnected() const { } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); - std::string ipInfo = "IP Address: " + connectedIP; + const std::string ipInfo = "IP Address: " + connectedIP; renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR); @@ -617,9 +627,9 @@ void WifiSelectionActivity::renderSavePrompt() const { // Draw Yes/No buttons const int buttonY = top + 80; - const int buttonWidth = 60; - const int buttonSpacing = 30; - const int totalWidth = buttonWidth * 2 + buttonSpacing; + constexpr int buttonWidth = 60; + constexpr int buttonSpacing = 30; + constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Yes" button @@ -667,9 +677,9 @@ void WifiSelectionActivity::renderForgetPrompt() const { // Draw Yes/No buttons const int buttonY = top + 80; - const int buttonWidth = 60; - const int buttonSpacing = 30; - const int totalWidth = buttonWidth * 2 + buttonSpacing; + constexpr int buttonWidth = 60; + constexpr int buttonSpacing = 30; + constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Yes" button diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index a009de1c..1cba2a4a 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -9,8 +9,7 @@ #include #include -#include "../Activity.h" -#include "../util/KeyboardEntryActivity.h" +#include "activities/ActivityWithSubactivity.h" // Structure to hold WiFi network information struct WifiNetworkInfo { @@ -43,7 +42,7 @@ enum class WifiSelectionState { * * The onComplete callback receives true if connected successfully, false if cancelled. */ -class WifiSelectionActivity final : public Activity { +class WifiSelectionActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; @@ -56,9 +55,6 @@ class WifiSelectionActivity final : public Activity { std::string selectedSSID; bool selectedRequiresPassword = false; - // On-screen keyboard for password entry - std::unique_ptr keyboard; - // Connection result std::string connectedIP; std::string connectionError; @@ -98,7 +94,7 @@ class WifiSelectionActivity final : public Activity { public: explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onComplete) - : Activity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} + : ActivityWithSubactivity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 84abc49c..fd9a8135 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "Battery.h" diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 07fd456d..1cda06ea 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -1,6 +1,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include +#include #include #include "config.h" diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index fc39a8c2..a6c10834 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -1,6 +1,7 @@ #include "FileSelectionActivity.h" #include +#include #include #include "config.h" diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index d38d85c7..a6c77140 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,6 +1,7 @@ #include "SettingsActivity.h" #include +#include #include "CrossPointSettings.h" #include "config.h" diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp similarity index 100% rename from src/activities/network/server/CrossPointWebServer.cpp rename to src/network/CrossPointWebServer.cpp diff --git a/src/activities/network/server/CrossPointWebServer.h b/src/network/CrossPointWebServer.h similarity index 96% rename from src/activities/network/server/CrossPointWebServer.h rename to src/network/CrossPointWebServer.h index 79c0b8ea..16983b0b 100644 --- a/src/activities/network/server/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,8 +2,6 @@ #include -#include -#include #include // Structure to hold file information diff --git a/src/html/FilesPageFooter.html b/src/network/html/FilesPageFooter.html similarity index 100% rename from src/html/FilesPageFooter.html rename to src/network/html/FilesPageFooter.html diff --git a/src/html/FilesPageHeader.html b/src/network/html/FilesPageHeader.html similarity index 100% rename from src/html/FilesPageHeader.html rename to src/network/html/FilesPageHeader.html diff --git a/src/html/HomePage.html b/src/network/html/HomePage.html similarity index 100% rename from src/html/HomePage.html rename to src/network/html/HomePage.html From ce37c80c2dc04d314018024fcf31f23807395ca2 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sun, 21 Dec 2025 14:53:55 +0100 Subject: [PATCH 21/33] Improve power button hold measurement for boot (#95) Improves the duration for which the power button needs to be held - see #53. I left the measurement code for the calibration value in, as it will likely change if we move the settings to NVS. --- src/main.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0d217812..cf74479a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -60,6 +60,9 @@ EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font); // Auto-sleep timeout (10 minutes of inactivity) constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000; +// measurement of power button press duration calibration value +unsigned long t1 = 0; +unsigned long t2 = 0; void exitActivity() { if (currentActivity) { @@ -79,6 +82,10 @@ void verifyWakeupLongPress() { // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() const auto start = millis(); bool abort = false; + // It takes us some time to wake up from deep sleep, so we need to subtract that from the duration + uint16_t calibration = 25; + uint16_t calibratedPressDuration = + (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; inputManager.update(); // Verify the user has actually pressed @@ -87,13 +94,13 @@ void verifyWakeupLongPress() { inputManager.update(); } + t2 = millis(); if (inputManager.isPressed(InputManager::BTN_POWER)) { do { delay(10); inputManager.update(); - } while (inputManager.isPressed(InputManager::BTN_POWER) && - inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration()); - abort = inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration(); + } while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration); + abort = inputManager.getHeldTime() < calibratedPressDuration; } else { abort = true; } @@ -120,7 +127,7 @@ void enterDeepSleep() { enterNewActivity(new SleepActivity(renderer, inputManager)); einkDisplay.deepSleep(); - + Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Ensure that the power button has been released to avoid immediately turning back on if you're holding it @@ -152,6 +159,7 @@ void onGoHome() { } void setup() { + t1 = millis(); Serial.begin(115200); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); From 689b539c6b53bcba6495f653c5d09dec99ea4d31 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 03:19:49 +1100 Subject: [PATCH 22/33] Stream CrossPointWebServer data over JSON APIs (#97) ## Summary * HTML files are now static, streamed directly to the client without modification * For any dynamic values, load via JSON APIs * For files page, we stream the JSON content as we scan the directory to avoid holding onto too much data ## Additional details * We were previously building up a very large string all generated on the X4 directly, we should be leveraging the browser * Fixes https://github.com/daveallie/crosspoint-reader/issues/94 --- platformio.ini | 3 +- src/network/CrossPointWebServer.cpp | 342 +++------- src/network/CrossPointWebServer.h | 27 +- src/network/html/FilesPage.html | 859 ++++++++++++++++++++++++++ src/network/html/FilesPageFooter.html | 233 ------- src/network/html/FilesPageHeader.html | 472 -------------- src/network/html/HomePage.html | 27 +- 7 files changed, 970 insertions(+), 993 deletions(-) create mode 100644 src/network/html/FilesPage.html delete mode 100644 src/network/html/FilesPageFooter.html delete mode 100644 src/network/html/FilesPageHeader.html diff --git a/platformio.ini b/platformio.ini index a4bdcd19..0048dd8a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 check_tool = cppcheck -check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --inline-suppr +check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr check_skip_packages = yes board_upload.flash_size = 16MB @@ -39,6 +39,7 @@ lib_deps = BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay + ArduinoJson @ 7.4.2 [env:default] extends = base diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 62916277..10159aba 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,53 +1,19 @@ #include "CrossPointWebServer.h" +#include #include #include #include -#include "config.h" -#include "html/FilesPageFooterHtml.generated.h" -#include "html/FilesPageHeaderHtml.generated.h" +#include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" namespace { - // Folders/files to hide from the web interface file browser // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; -const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); - -// Helper function to escape HTML special characters to prevent XSS -String escapeHtml(const String& input) { - String output; - output.reserve(input.length() * 1.1); // Pre-allocate with some extra space - - for (size_t i = 0; i < input.length(); i++) { - char c = input.charAt(i); - switch (c) { - case '&': - output += "&"; - break; - case '<': - output += "<"; - break; - case '>': - output += ">"; - break; - case '"': - output += """; - break; - case '\'': - output += "'"; - break; - default: - output += c; - break; - } - } - return output; -} - +constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); } // namespace // File listing page template - now using generated headers: @@ -72,7 +38,7 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); - server = new WebServer(port); + server.reset(new WebServer(port)); Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); if (!server) { @@ -82,20 +48,22 @@ void CrossPointWebServer::begin() { // Setup routes Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); - server->on("/", HTTP_GET, [this]() { handleRoot(); }); - server->on("/status", HTTP_GET, [this]() { handleStatus(); }); - server->on("/files", HTTP_GET, [this]() { handleFileList(); }); + server->on("/", HTTP_GET, [this] { handleRoot(); }); + server->on("/files", HTTP_GET, [this] { handleFileList(); }); + + server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); + server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); // Upload endpoint with special handling for multipart form data - server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); }); + server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); // Create folder endpoint - server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); }); + server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); }); // Delete file/folder endpoint - server->on("/delete", HTTP_POST, [this]() { handleDelete(); }); + server->on("/delete", HTTP_POST, [this] { handleDelete(); }); - server->onNotFound([this]() { handleNotFound(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); @@ -108,7 +76,8 @@ void CrossPointWebServer::begin() { void CrossPointWebServer::stop() { if (!running || !server) { - Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, server); + Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, + server.get()); return; } @@ -128,9 +97,7 @@ void CrossPointWebServer::stop() { delay(50); Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); - delete server; - server = nullptr; - + server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -139,7 +106,7 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServer::handleClient() { +void CrossPointWebServer::handleClient() const { static unsigned long lastDebugPrint = 0; // Check running flag FIRST before accessing server @@ -162,25 +129,18 @@ void CrossPointWebServer::handleClient() { server->handleClient(); } -void CrossPointWebServer::handleRoot() { - String html = HomePageHtml; - - // Replace placeholders with actual values - html.replace("%VERSION%", CROSSPOINT_VERSION); - html.replace("%IP_ADDRESS%", WiFi.localIP().toString()); - html.replace("%FREE_HEAP%", String(ESP.getFreeHeap())); - - server->send(200, "text/html", html); +void CrossPointWebServer::handleRoot() const { + server->send(200, "text/html", HomePageHtml); Serial.printf("[%lu] [WEB] Served root page\n", millis()); } -void CrossPointWebServer::handleNotFound() { +void CrossPointWebServer::handleNotFound() const { String message = "404 Not Found\n\n"; message += "URI: " + server->uri() + "\n"; server->send(404, "text/plain", message); } -void CrossPointWebServer::handleStatus() { +void CrossPointWebServer::handleStatus() const { String json = "{"; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; @@ -192,26 +152,24 @@ void CrossPointWebServer::handleStatus() { server->send(200, "application/json", json); } -std::vector CrossPointWebServer::scanFiles(const char* path) { - std::vector files; - +void CrossPointWebServer::scanFiles(const char* path, const std::function& callback) const { File root = SD.open(path); if (!root) { Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); - return files; + return; } if (!root.isDirectory()) { Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); root.close(); - return files; + return; } Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); File file = root.openNextFile(); while (file) { - String fileName = String(file.name()); + auto fileName = String(file.name()); // Skip hidden items (starting with ".") bool shouldHide = fileName.startsWith("."); @@ -239,37 +197,24 @@ std::vector CrossPointWebServer::scanFiles(const char* path) { info.isEpub = isEpubFile(info.name); } - files.push_back(info); + callback(info); } file.close(); file = root.openNextFile(); } root.close(); - - Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size()); - return files; } -String CrossPointWebServer::formatFileSize(size_t bytes) { - if (bytes < 1024) { - return String(bytes) + " B"; - } else if (bytes < 1024 * 1024) { - return String(bytes / 1024.0, 1) + " KB"; - } else { - return String(bytes / (1024.0 * 1024.0), 1) + " MB"; - } -} - -bool CrossPointWebServer::isEpubFile(const String& filename) { +bool CrossPointWebServer::isEpubFile(const String& filename) const { String lower = filename; lower.toLowerCase(); return lower.endsWith(".epub"); } -void CrossPointWebServer::handleFileList() { - String html = FilesPageHeaderHtml; +void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); } +void CrossPointWebServer::handleFileListData() const { // Get current path from query string (default to root) String currentPath = "/"; if (server->hasArg("path")) { @@ -284,180 +229,35 @@ void CrossPointWebServer::handleFileList() { } } - // Get message from query string if present - if (server->hasArg("msg")) { - String msg = escapeHtml(server->arg("msg")); - String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success"; - html += "
" + msg + "
"; - } + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + char output[512]; + constexpr size_t outputSize = sizeof(output); + bool seenFirst = false; + scanFiles(currentPath.c_str(), [this, &output, seenFirst](const FileInfo& info) mutable { + JsonDocument doc; + doc["name"] = info.name; + doc["size"] = info.size; + doc["isDirectory"] = info.isDirectory; + doc["isEpub"] = info.isEpub; + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) { + // JSON output truncated; skip this entry to avoid sending malformed JSON + Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str()); + return; + } - // Hidden input to store current path for JavaScript - html += ""; - - // Scan files in current path first (we need counts for the header) - std::vector files = scanFiles(currentPath.c_str()); - - // Count items - int epubCount = 0; - int folderCount = 0; - size_t totalSize = 0; - for (const auto& file : files) { - if (file.isDirectory) { - folderCount++; + if (seenFirst) { + server->sendContent(","); } else { - if (file.isEpub) epubCount++; - totalSize += file.size; + seenFirst = true; } - } - - // Page header with inline breadcrumb and action buttons - html += "
"; - html += "
"; - html += "

📁 File Manager

"; - - // Inline breadcrumb - html += "
"; - html += "/"; - - if (currentPath == "/") { - html += "🏠"; - } else { - html += "🏠"; - String pathParts = currentPath.substring(1); // Remove leading / - String buildPath = ""; - int start = 0; - int end = pathParts.indexOf('/'); - - while (start < (int)pathParts.length()) { - String part; - if (end == -1) { - part = pathParts.substring(start); - buildPath += "/" + part; - html += "/" + escapeHtml(part) + ""; - break; - } else { - part = pathParts.substring(start, end); - buildPath += "/" + part; - html += "/" + escapeHtml(part) + ""; - start = end + 1; - end = pathParts.indexOf('/', start); - } - } - } - html += "
"; - html += "
"; - - // Action buttons - html += "
"; - html += ""; - html += ""; - html += "
"; - - html += "
"; // end page-header - - // Contents card with inline summary - html += "
"; - - // Contents header with inline stats - html += "
"; - html += "

Contents

"; - html += ""; - html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", "; - html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", "; - html += formatFileSize(totalSize); - html += ""; - html += "
"; - - if (files.empty()) { - html += "
This folder is empty
"; - } else { - html += ""; - html += ""; - - // Sort files: folders first, then epub files, then other files, alphabetically within each group - std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) { - // Folders come first - if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory; - // Then sort by epub status (epubs first among files) - if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; - // Then alphabetically - return a.name < b.name; - }); - - for (const auto& file : files) { - String rowClass; - String icon; - String badge; - String typeStr; - String sizeStr; - - if (file.isDirectory) { - rowClass = "folder-row"; - icon = "📁"; - badge = "FOLDER"; - typeStr = "Folder"; - sizeStr = "-"; - - // Build the path to this folder - String folderPath = currentPath; - if (!folderPath.endsWith("/")) folderPath += "/"; - folderPath += file.name; - - html += ""; - html += ""; - html += ""; - html += ""; - // Escape quotes for JavaScript string - String escapedName = file.name; - escapedName.replace("'", "\\'"); - String escapedPath = folderPath; - escapedPath.replace("'", "\\'"); - html += ""; - html += ""; - } else { - rowClass = file.isEpub ? "epub-file" : ""; - icon = file.isEpub ? "📗" : "📄"; - badge = file.isEpub ? "EPUB" : ""; - String ext = file.name.substring(file.name.lastIndexOf('.') + 1); - ext.toUpperCase(); - typeStr = ext; - sizeStr = formatFileSize(file.size); - - // Build file path for delete - String filePath = currentPath; - if (!filePath.endsWith("/")) filePath += "/"; - filePath += file.name; - - html += ""; - html += ""; - html += ""; - html += ""; - // Escape quotes for JavaScript string - String escapedName = file.name; - escapedName.replace("'", "\\'"); - String escapedPath = filePath; - escapedPath.replace("'", "\\'"); - html += ""; - html += ""; - } - } - - html += "
NameTypeSizeActions
" + icon + ""; - html += "" + escapeHtml(file.name) + "" + - badge + "" + typeStr + "" + sizeStr + "
" + icon + "" + escapeHtml(file.name) + badge + "" + typeStr + "" + sizeStr + "
"; - } - - html += "
"; - - html += FilesPageFooterHtml; - - server->send(200, "text/html", html); + server->sendContent(output); + }); + server->sendContent("]"); + // End of streamed response, empty chunk to signal client + server->sendContent(""); Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); } @@ -469,7 +269,7 @@ static size_t uploadSize = 0; static bool uploadSuccess = false; static String uploadError = ""; -void CrossPointWebServer::handleUpload() { +void CrossPointWebServer::handleUpload() const { static unsigned long lastWriteTime = 0; static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; @@ -480,7 +280,7 @@ void CrossPointWebServer::handleUpload() { return; } - HTTPUpload& upload = server->upload(); + const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { uploadFileName = upload.filename; @@ -533,10 +333,10 @@ void CrossPointWebServer::handleUpload() { Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - unsigned long writeStartTime = millis(); - size_t written = uploadFile.write(upload.buf, upload.currentSize); - unsigned long writeEndTime = millis(); - unsigned long writeDuration = writeEndTime - writeStartTime; + const unsigned long writeStartTime = millis(); + const size_t written = uploadFile.write(upload.buf, upload.currentSize); + const unsigned long writeEndTime = millis(); + const unsigned long writeDuration = writeEndTime - writeStartTime; if (written != upload.currentSize) { uploadError = "Failed to write to SD card - disk may be full"; @@ -548,9 +348,9 @@ void CrossPointWebServer::handleUpload() { // Log progress every 50KB or if write took >100ms if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - unsigned long timeSinceStart = millis() - uploadStartTime; - unsigned long timeSinceLastWrite = millis() - lastWriteTime; - float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + const unsigned long timeSinceStart = millis() - uploadStartTime; + const unsigned long timeSinceLastWrite = millis() - lastWriteTime; + const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); Serial.printf( "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " @@ -584,23 +384,23 @@ void CrossPointWebServer::handleUpload() { } } -void CrossPointWebServer::handleUploadPost() { +void CrossPointWebServer::handleUploadPost() const { if (uploadSuccess) { server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName); } else { - String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; + const String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; server->send(400, "text/plain", error); } } -void CrossPointWebServer::handleCreateFolder() { +void CrossPointWebServer::handleCreateFolder() const { // Get folder name from form data if (!server->hasArg("name")) { server->send(400, "text/plain", "Missing folder name"); return; } - String folderName = server->arg("name"); + const String folderName = server->arg("name"); // Validate folder name if (folderName.isEmpty()) { @@ -643,7 +443,7 @@ void CrossPointWebServer::handleCreateFolder() { } } -void CrossPointWebServer::handleDelete() { +void CrossPointWebServer::handleDelete() const { // Get path from form data if (!server->hasArg("path")) { server->send(400, "text/plain", "Missing path"); @@ -651,7 +451,7 @@ void CrossPointWebServer::handleDelete() { } String itemPath = server->arg("path"); - String itemType = server->hasArg("type") ? server->arg("type") : "file"; + const String itemType = server->hasArg("type") ? server->arg("type") : "file"; // Validate path if (itemPath.isEmpty() || itemPath == "/") { @@ -665,7 +465,7 @@ void CrossPointWebServer::handleDelete() { } // Security check: prevent deletion of protected items - String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); // Check if item starts with a dot (hidden/system file) if (itemName.startsWith(".")) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 16983b0b..327897fb 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -24,7 +24,7 @@ class CrossPointWebServer { void stop(); // Call this periodically to handle client requests - void handleClient(); + void handleClient() const; // Check if server is running bool isRunning() const { return running; } @@ -33,22 +33,23 @@ class CrossPointWebServer { uint16_t getPort() const { return port; } private: - WebServer* server = nullptr; + std::unique_ptr server = nullptr; bool running = false; uint16_t port = 80; // File scanning - std::vector scanFiles(const char* path = "/"); - String formatFileSize(size_t bytes); - bool isEpubFile(const String& filename); + void scanFiles(const char* path, const std::function& callback) const; + String formatFileSize(size_t bytes) const; + bool isEpubFile(const String& filename) const; // Request handlers - void handleRoot(); - void handleNotFound(); - void handleStatus(); - void handleFileList(); - void handleUpload(); - void handleUploadPost(); - void handleCreateFolder(); - void handleDelete(); + void handleRoot() const; + void handleNotFound() const; + void handleStatus() const; + void handleFileList() const; + void handleFileListData() const; + void handleUpload() const; + void handleUploadPost() const; + void handleCreateFolder() const; + void handleDelete() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html new file mode 100644 index 00000000..f58200f3 --- /dev/null +++ b/src/network/html/FilesPage.html @@ -0,0 +1,859 @@ + + + + + + CrossPoint Reader - Files + + + + + + + +
+
+

Contents

+ +
+ +
+
+ +
+
+
+ +
+

+ CrossPoint E-Reader • Open Source +

+
+ + + + + + + + + + + + + diff --git a/src/network/html/FilesPageFooter.html b/src/network/html/FilesPageFooter.html deleted file mode 100644 index 961753a6..00000000 --- a/src/network/html/FilesPageFooter.html +++ /dev/null @@ -1,233 +0,0 @@ -
-

- CrossPoint E-Reader • Open Source -

-
- - - - - - - - - - - - - diff --git a/src/network/html/FilesPageHeader.html b/src/network/html/FilesPageHeader.html deleted file mode 100644 index 7ebfc883..00000000 --- a/src/network/html/FilesPageHeader.html +++ /dev/null @@ -1,472 +0,0 @@ - - - - - - CrossPoint Reader - Files - - - - - - diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index 024c6a92..b464cf34 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -83,7 +83,7 @@

Device Status

Version - %VERSION% +
WiFi Status @@ -91,11 +91,11 @@
IP Address - %IP_ADDRESS% +
Free Memory - %FREE_HEAP% bytes +
@@ -104,5 +104,26 @@ CrossPoint E-Reader • Open Source

+ From 6fe28da41b4c0218c4a75d0a68d346f4354bd958 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 03:20:22 +1100 Subject: [PATCH 23/33] Cut release 0.8.1 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 0048dd8a..a44dd476 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.8.0 +crosspoint_version = 0.8.1 default_envs = default [base] From f4491875ab7e076678dddd1e8950ec73aa086b4b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 17:16:39 +1100 Subject: [PATCH 24/33] Thoroughly deinitialise expat parsers before freeing them (#103) ## Summary * Thoroughly deinitialise expat parsers before freeing them * Spotted a few crashes when de-initing expat parsers --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 +++++++++++++++---- lib/Epub/Epub/parsers/ContainerParser.cpp | 2 ++ lib/Epub/Epub/parsers/ContentOpfParser.cpp | 9 +++++++++ lib/Epub/Epub/parsers/TocNcxParser.cpp | 13 ++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 718f4d73..a6297070 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -214,10 +214,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - XML_SetUserData(parser, this); - XML_SetElementHandler(parser, startElement, endElement); - XML_SetCharacterDataHandler(parser, characterData); - FILE* file = fopen(filepath, "r"); if (!file) { Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); @@ -225,10 +221,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); + do { void* const buf = XML_GetBuffer(parser, 1024); if (!buf) { Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; @@ -238,6 +241,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { if (ferror(file)) { Serial.printf("[%lu] [EHP] File read error\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; @@ -248,12 +254,18 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; } } while (!done); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); diff --git a/lib/Epub/Epub/parsers/ContainerParser.cpp b/lib/Epub/Epub/parsers/ContainerParser.cpp index db126f25..da3a7b15 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.cpp +++ b/lib/Epub/Epub/parsers/ContainerParser.cpp @@ -16,6 +16,8 @@ bool ContainerParser::setup() { ContainerParser::~ContainerParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_ParserFree(parser); parser = nullptr; } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 5aa73032..4d3d776f 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -22,6 +22,9 @@ bool ContentOpfParser::setup() { ContentOpfParser::~ContentOpfParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; } @@ -40,6 +43,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) { if (!buf) { Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; return 0; @@ -51,6 +57,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) { if (XML_ParseBuffer(parser, static_cast(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; return 0; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index 0a613f33..f4700558 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -18,6 +18,9 @@ bool TocNcxParser::setup() { TocNcxParser::~TocNcxParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; } @@ -35,6 +38,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) { void* const buf = XML_GetBuffer(parser, 1024); if (!buf) { Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; return 0; } @@ -44,6 +52,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) { if (XML_ParseBuffer(parser, static_cast(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; return 0; } From d23020e268d15302147c0474fa8140d1aa6fba53 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 17:16:46 +1100 Subject: [PATCH 25/33] OTA updates (#96) ## Summary * Adds support for OTA * Gets latest firmware bin from latest GitHub release * I have noticed it be a little flaky unpacking the JSON and occasionally failing to start --- src/activities/settings/OtaUpdateActivity.cpp | 242 ++++++++++++++++++ src/activities/settings/OtaUpdateActivity.h | 43 ++++ src/activities/settings/SettingsActivity.cpp | 30 ++- src/activities/settings/SettingsActivity.h | 11 +- src/network/OtaUpdater.cpp | 169 ++++++++++++ src/network/OtaUpdater.h | 30 +++ 6 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 src/activities/settings/OtaUpdateActivity.cpp create mode 100644 src/activities/settings/OtaUpdateActivity.h create mode 100644 src/network/OtaUpdater.cpp create mode 100644 src/network/OtaUpdater.h diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp new file mode 100644 index 00000000..d31f4103 --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -0,0 +1,242 @@ +#include "OtaUpdateActivity.h" + +#include +#include +#include + +#include "activities/network/WifiSelectionActivity.h" +#include "config.h" +#include "network/OtaUpdater.h" + +void OtaUpdateActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { + exitActivity(); + + if (!success) { + Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis()); + goBack(); + return; + } + + Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis()); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = CHECKING_FOR_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.checkForUpdate(); + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + if (!updater.isUpdateNewer()) { + Serial.printf("[%lu] [OTA] No new update available\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = NO_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = WAITING_CONFIRMATION; + xSemaphoreGive(renderingMutex); + updateRequired = true; +} + +void OtaUpdateActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Turn on WiFi immediately + Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis()); + WiFi.mode(WIFI_STA); + + // Launch WiFi selection subactivity + Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} + +void OtaUpdateActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Turn off wifi + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + delay(100); // Allow disconnect frame to be sent + WiFi.mode(WIFI_OFF); + delay(100); // Allow WiFi hardware to fully power down + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void OtaUpdateActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OtaUpdateActivity::render() { + if (subActivity) { + // Subactivity handles its own rendering + return; + } + + float updaterProgress = 0; + if (state == UPDATE_IN_PROGRESS) { + Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize); + updaterProgress = static_cast(updater.processedSize) / static_cast(updater.totalSize); + // Only update every 2% at the most + if (static_cast(updaterProgress * 50) == lastUpdaterPercentage / 2) { + return; + } + lastUpdaterPercentage = static_cast(updaterProgress * 100); + } + + const auto pageHeight = renderer.getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + + renderer.clearScreen(); + renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD); + + if (state == CHECKING_FOR_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == WAITING_CONFIRMATION) { + renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD); + renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION); + renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); + + renderer.drawRect(25, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35, + "Cancel"); + + renderer.drawRect(130, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35, + "Update"); + renderer.displayBuffer(); + return; + } + + if (state == UPDATE_IN_PROGRESS) { + renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD); + renderer.drawRect(20, 350, pageWidth - 40, 50); + renderer.fillRect(24, 354, static_cast(updaterProgress * static_cast(pageWidth - 44)), 42); + renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); + renderer.drawCenteredText( + UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str()); + renderer.displayBuffer(); + return; + } + + if (state == NO_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FINISHED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD); + renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on"); + renderer.displayBuffer(); + state = SHUTTING_DOWN; + return; + } +} + +void OtaUpdateActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (state == WAITING_CONFIRMATION) { + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = UPDATE_IN_PROGRESS; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; }); + + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FINISHED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + + return; + } + + if (state == FAILED) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == NO_UPDATE) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == SHUTTING_DOWN) { + ESP.restart(); + } +} diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h new file mode 100644 index 00000000..20be6faa --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/OtaUpdater.h" + +class OtaUpdateActivity : public ActivityWithSubactivity { + enum State { + WIFI_SELECTION, + CHECKING_FOR_UPDATE, + WAITING_CONFIRMATION, + UPDATE_IN_PROGRESS, + NO_UPDATE, + FAILED, + FINISHED, + SHUTTING_DOWN + }; + + // Can't initialize this to 0 or the first render doesn't happen + static constexpr unsigned int UNINITIALIZED_PERCENTAGE = 111; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function goBack; + State state = WIFI_SELECTION; + unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE; + OtaUpdater updater; + + void onWifiSelectionComplete(bool success); + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + + public: + explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& goBack) + : ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a6c77140..37f2e5a1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -4,16 +4,19 @@ #include #include "CrossPointSettings.h" +#include "OtaUpdateActivity.h" #include "config.h" // Define the static settings list namespace { -constexpr int settingsCount = 3; +constexpr int settingsCount = 4; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, + {"Check for updates", SettingType::ACTION, nullptr, {}}, +}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -41,7 +44,7 @@ void SettingsActivity::onEnter() { } void SettingsActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -54,6 +57,11 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + // Handle actions with early return if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { toggleCurrentSetting(); @@ -81,7 +89,7 @@ void SettingsActivity::loop() { } } -void SettingsActivity::toggleCurrentSetting() const { +void SettingsActivity::toggleCurrentSetting() { // Validate index if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; @@ -96,6 +104,16 @@ void SettingsActivity::toggleCurrentSetting() const { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::ACTION) { + if (std::string(setting.name) == "Check for updates") { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } } else { // Only toggle if it's a toggle type and has a value pointer return; @@ -107,7 +125,7 @@ void SettingsActivity::toggleCurrentSetting() const { void SettingsActivity::displayTaskLoop() { while (true) { - if (updateRequired) { + if (updateRequired && !subActivity) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); @@ -152,6 +170,8 @@ void SettingsActivity::render() const { // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), + pageHeight - 30, CROSSPOINT_VERSION); // Always use standard refresh for settings screen renderer.displayBuffer(); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 6fe5db1f..d88dc85f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -3,16 +3,15 @@ #include #include -#include #include #include #include -#include "../Activity.h" +#include "activities/ActivityWithSubactivity.h" class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM }; +enum class SettingType { TOGGLE, ENUM, ACTION }; // Structure to hold setting information struct SettingInfo { @@ -22,7 +21,7 @@ struct SettingInfo { std::vector enumValues; }; -class SettingsActivity final : public Activity { +class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; @@ -32,11 +31,11 @@ class SettingsActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void toggleCurrentSetting() const; + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {} + : ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp new file mode 100644 index 00000000..249c4570 --- /dev/null +++ b/src/network/OtaUpdater.cpp @@ -0,0 +1,169 @@ +#include "OtaUpdater.h" + +#include +#include +#include +#include + +namespace { +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +} + +OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl); + + http.begin(*client, latestReleaseUrl); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + JsonDocument doc; + const DeserializationError error = deserializeJson(doc, *client); + http.end(); + if (error) { + Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); + return JSON_PARSE_ERROR; + } + + if (!doc["tag_name"].is()) { + Serial.printf("[%lu] [OTA] No tag_name found\n", millis()); + return JSON_PARSE_ERROR; + } + if (!doc["assets"].is()) { + Serial.printf("[%lu] [OTA] No assets found\n", millis()); + return JSON_PARSE_ERROR; + } + + latestVersion = doc["tag_name"].as(); + + for (int i = 0; i < doc["assets"].size(); i++) { + if (doc["assets"][i]["name"] == "firmware.bin") { + otaUrl = doc["assets"][i]["browser_download_url"].as(); + otaSize = doc["assets"][i]["size"].as(); + totalSize = otaSize; + updateAvailable = true; + break; + } + } + + if (!updateAvailable) { + Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis()); + return NO_UPDATE; + } + + Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str()); + return OK; +} + +bool OtaUpdater::isUpdateNewer() { + if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) { + return false; + } + + // semantic version check (only match on 3 segments) + const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.'))); + const auto updateMinor = stoi( + latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1)); + const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1)); + + std::string currentVersion = CROSSPOINT_VERSION; + const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.'))); + const auto currentMinor = stoi(currentVersion.substr( + currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1)); + const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1)); + + if (updateMajor > currentMajor) { + return true; + } + if (updateMajor < currentMajor) { + return false; + } + + if (updateMinor > currentMinor) { + return true; + } + if (updateMinor < currentMinor) { + return false; + } + + if (updatePatch > currentPatch) { + return true; + } + return false; +} + +const std::string& OtaUpdater::getLatestVersion() { return latestVersion; } + +OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function& onProgress) { + if (!isUpdateNewer()) { + return UPDATE_OLDER_ERROR; + } + + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str()); + + http.begin(*client, otaUrl.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + const int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + // 2. Get length and stream + const size_t contentLength = http.getSize(); + + if (contentLength != otaSize) { + Serial.printf("[%lu] [OTA] Invalid content length\n", millis()); + http.end(); + return HTTP_ERROR; + } + + // 3. Begin the ESP-IDF Update process + if (!Update.begin(otaSize)) { + Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString()); + http.end(); + return INTERNAL_UPDATE_ERROR; + } + + this->totalSize = otaSize; + Serial.printf("[%lu] [OTA] Update started\n", millis()); + Update.onProgress([this, onProgress](const size_t progress, const size_t total) { + this->processedSize = progress; + this->totalSize = total; + onProgress(progress, total); + }); + const size_t written = Update.writeStream(*client); + http.end(); + + if (written == otaSize) { + Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written); + } else { + Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize, + Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } + + if (Update.end() && Update.isFinished()) { + Serial.printf("[%lu] [OTA] Update complete\n", millis()); + return OK; + } else { + Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } +} diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h new file mode 100644 index 00000000..dfaee88a --- /dev/null +++ b/src/network/OtaUpdater.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +class OtaUpdater { + bool updateAvailable = false; + std::string latestVersion; + std::string otaUrl; + size_t otaSize = 0; + + public: + enum OtaUpdaterError { + OK = 0, + NO_UPDATE, + HTTP_ERROR, + JSON_PARSE_ERROR, + UPDATE_OLDER_ERROR, + INTERNAL_UPDATE_ERROR, + OOM_ERROR, + }; + size_t processedSize = 0; + size_t totalSize = 0; + + OtaUpdater() = default; + bool isUpdateNewer(); + const std::string& getLatestVersion(); + OtaUpdaterError checkForUpdate(); + OtaUpdaterError installUpdate(const std::function& onProgress); +}; From 9f4f71fabe1ddd5e7bb68f58e7831161e354bb5e Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Mon, 22 Dec 2025 01:24:14 -0500 Subject: [PATCH 26/33] Add AP mode option for file transfers (#98) ## Summary * **What is the goal of this PR?** Adds WiFi Access Point (AP) mode support for File Transfer, allowing the device to create its own WiFi network that users can connect to directly - useful when no existing WiFi network is available. And in my experience is faster when the device is right next to your laptop (but maybe further from your wifi) * **What changes are included?** - New `NetworkModeSelectionActivity` - an interstitial screen asking users to choose between: - "Join a Network" - connects to an existing WiFi network (existing behavior) - "Create Hotspot" - creates a WiFi access point named "CrossPoint-Reader" - Modified `CrossPointWebServerActivity` to: - Launch the network mode selection screen before proceeding - Support starting an Access Point with mDNS (`crosspoint.local`) and DNS server for captive portal behavior - Display appropriate connection info for both modes - Modified `CrossPointWebServer` to support starting when WiFi is in AP mode (not just STA connected mode) ## Additional Context * **AP Mode Details**: The device creates an open WiFi network named "CrossPoint-Reader". Once connected, users can access the file transfer page at `http://crosspoint.local/` or `http://192.168.4.1/` * **DNS Captive Portal**: A DNS server redirects all domain requests to the device's IP, enabling captive portal behavior on some devices * **mDNS**: Hostname resolution via `crosspoint.local` is enabled for both AP and STA modes * **No breaking changes**: The "Join a Network" option preserves the existing WiFi connection flow * **Memory impact**: Minimal - the AP mode uses roughly the same resources as STA mode --- .../network/CrossPointWebServerActivity.cpp | 228 +++++++++++++++--- .../network/CrossPointWebServerActivity.h | 21 +- .../network/NetworkModeSelectionActivity.cpp | 128 ++++++++++ .../network/NetworkModeSelectionActivity.h | 41 ++++ src/network/CrossPointWebServer.cpp | 26 +- src/network/CrossPointWebServer.h | 1 + 6 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 src/activities/network/NetworkModeSelectionActivity.cpp create mode 100644 src/activities/network/NetworkModeSelectionActivity.h diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 5bf571a9..b9c911e2 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -1,12 +1,28 @@ #include "CrossPointWebServerActivity.h" +#include +#include #include #include #include +#include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "config.h" +namespace { +// AP Mode configuration +constexpr const char* AP_SSID = "CrossPoint-Reader"; +constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use +constexpr const char* AP_HOSTNAME = "crosspoint"; +constexpr uint8_t AP_CHANNEL = 1; +constexpr uint8_t AP_MAX_CONNECTIONS = 4; + +// DNS server for captive portal (redirects all DNS queries to our IP) +DNSServer* dnsServer = nullptr; +constexpr uint16_t DNS_PORT = 53; +} // namespace + void CrossPointWebServerActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -20,7 +36,9 @@ void CrossPointWebServerActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); // Reset state - state = WebServerActivityState::WIFI_SELECTION; + state = WebServerActivityState::MODE_SELECTION; + networkMode = NetworkMode::JOIN_NETWORK; + isApMode = false; connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; @@ -33,14 +51,12 @@ void CrossPointWebServerActivity::onEnter() { &displayTaskHandle // Task handle ); - // Turn on WiFi immediately - Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis()); - WiFi.mode(WIFI_STA); - - // Launch WiFi selection subactivity - Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - enterNewActivity(new WifiSelectionActivity(renderer, inputManager, - [this](const bool connected) { onWifiSelectionComplete(connected); })); + // Launch network mode selection subactivity + Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); } // Cancel goes back to home + )); } void CrossPointWebServerActivity::onExit() { @@ -53,14 +69,30 @@ void CrossPointWebServerActivity::onExit() { // Stop the web server first (before disconnecting WiFi) stopWebServer(); + // Stop mDNS + MDNS.end(); + + // Stop DNS server if running (AP mode) + if (dnsServer) { + Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); + dnsServer->stop(); + delete dnsServer; + dnsServer = nullptr; + } + // CRITICAL: Wait for LWIP stack to flush any pending packets Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); delay(500); // Disconnect WiFi gracefully - Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); - WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame - delay(100); // Allow disconnect frame to be sent + if (isApMode) { + Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); + WiFi.softAPdisconnect(true); + } else { + Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + } + delay(100); // Allow disconnect frame to be sent Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); @@ -89,6 +121,33 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); } +void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), + mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + + networkMode = mode; + isApMode = (mode == NetworkMode::CREATE_HOTSPOT); + + // Exit mode selection subactivity + exitActivity(); + + if (mode == NetworkMode::JOIN_NETWORK) { + // STA mode - launch WiFi selection + Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); + WiFi.mode(WIFI_STA); + + state = WebServerActivityState::WIFI_SELECTION; + Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + // AP mode - start access point + state = WebServerActivityState::AP_STARTING; + updateRequired = true; + startAccessPoint(); + } +} + void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); @@ -96,17 +155,83 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) // Get connection info before exiting subactivity connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); + isApMode = false; exitActivity(); + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } + // Start the web server startWebServer(); } else { - // User cancelled - go back - onGoBack(); + // User cancelled - go back to mode selection + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); })); } } +void CrossPointWebServerActivity::startAccessPoint() { + Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Configure and start the AP + WiFi.mode(WIFI_AP); + delay(100); + + // Start soft AP + bool apStarted; + if (AP_PASSWORD && strlen(AP_PASSWORD) >= 8) { + apStarted = WiFi.softAP(AP_SSID, AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } else { + // Open network (no password) + apStarted = WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } + + if (!apStarted) { + Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis()); + onGoBack(); + return; + } + + delay(100); // Wait for AP to fully initialize + + // Get AP IP address + const IPAddress apIP = WiFi.softAPIP(); + char ipStr[16]; + snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", apIP[0], apIP[1], apIP[2], apIP[3]); + connectedIP = ipStr; + connectedSSID = AP_SSID; + + Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis()); + Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID); + Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str()); + + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } else { + Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis()); + } + + // Start DNS server for captive portal behavior + // This redirects all DNS queries to our IP, making any domain typed resolve to us + dnsServer = new DNSServer(); + dnsServer->setErrorReplyCode(DNSReplyCode::NoError); + dnsServer->start(DNS_PORT, "*", apIP); + Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis()); + + Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Start the web server + startWebServer(); +} + void CrossPointWebServerActivity::startWebServer() { Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); @@ -150,6 +275,11 @@ void CrossPointWebServerActivity::loop() { // Handle different states if (state == WebServerActivityState::SERVER_RUNNING) { + // Handle DNS requests for captive portal (AP mode only) + if (isApMode && dnsServer) { + dnsServer->processNextRequest(); + } + // Handle web server requests - call handleClient multiple times per loop // to improve responsiveness and upload throughput if (webServer && webServer->isRunning()) { @@ -193,35 +323,71 @@ void CrossPointWebServerActivity::displayTaskLoop() { void CrossPointWebServerActivity::render() const { // Only render our own UI when server is running - // WiFi selection handles its own rendering + // Subactivities handle their own rendering if (state == WebServerActivityState::SERVER_RUNNING) { renderer.clearScreen(); renderServerRunning(); renderer.displayBuffer(); + } else if (state == WebServerActivityState::AP_STARTING) { + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD); + renderer.displayBuffer(); } } void CrossPointWebServerActivity::renderServerRunning() const { const auto pageHeight = renderer.getScreenHeight(); - const auto height = renderer.getLineHeight(UI_FONT_ID); - const auto top = (pageHeight - height * 5) / 2; - renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD); + // Use consistent line spacing + constexpr int LINE_SPACING = 28; // Space between lines - std::string ssidInfo = "Network: " + connectedSSID; - if (ssidInfo.length() > 28) { - ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD); + + if (isApMode) { + // AP mode display - center the content block + const int startY = 55; + + renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); + + std::string ssidInfo = "Network: " + connectedSSID; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network", + true, REGULAR); + + // Show primary URL (hostname) + std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); + + // Show IP address as fallback + std::string ipUrl = "or http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR); + } else { + // STA mode display (original behavior) + const int startY = 65; + + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR); + + std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR); + + // Show web server URL prominently + std::string webInfo = "http://" + connectedIP + "/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD); + + // Also show hostname URL + std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; + 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(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); - - std::string ipInfo = "IP Address: " + connectedIP; - renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); - - // Show web server URL prominently - std::string webInfo = "http://" + connectedIP + "/"; - renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD); - - renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 6889f6eb..038a0c4b 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -7,12 +7,15 @@ #include #include +#include "NetworkModeSelectionActivity.h" #include "activities/ActivityWithSubactivity.h" #include "network/CrossPointWebServer.h" // Web server activity states enum class WebServerActivityState { - WIFI_SELECTION, // WiFi selection subactivity is active + MODE_SELECTION, // Choosing between Join Network and Create Hotspot + WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode) + AP_STARTING, // Starting Access Point mode SERVER_RUNNING, // Web server is running and handling requests SHUTTING_DOWN // Shutting down server and WiFi }; @@ -20,8 +23,10 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - Immediately turns on WiFi and launches WifiSelectionActivity on enter - * - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer + * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - For STA mode: Launches WifiSelectionActivity to connect to an existing network + * - For AP mode: Creates an Access Point that clients can connect to + * - Starts the CrossPointWebServer when connected * - Handles client requests in its loop() function * - Cleans up the server and shuts down WiFi on exit */ @@ -29,15 +34,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; - WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; + WebServerActivityState state = WebServerActivityState::MODE_SELECTION; const std::function onGoBack; + // Network mode + NetworkMode networkMode = NetworkMode::JOIN_NETWORK; + bool isApMode = false; + // Web server - owned by this activity std::unique_ptr webServer; // Server status std::string connectedIP; - std::string connectedSSID; + std::string connectedSSID; // For STA mode: network name, For AP mode: AP name // Performance monitoring unsigned long lastHandleClientTime = 0; @@ -47,7 +56,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { void render() const; void renderServerRunning() const; + void onNetworkModeSelected(NetworkMode mode); void onWifiSelectionComplete(bool connected); + void startAccessPoint(); void startWebServer(); void stopWebServer(); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp new file mode 100644 index 00000000..637d82d9 --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -0,0 +1,128 @@ +#include "NetworkModeSelectionActivity.h" + +#include +#include + +#include "config.h" + +namespace { +constexpr int MENU_ITEM_COUNT = 2; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", + "Create a WiFi network others can join"}; +} // namespace + +void NetworkModeSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void NetworkModeSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void NetworkModeSelectionActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void NetworkModeSelectionActivity::loop() { + // Handle back button - cancel + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onCancel(); + return; + } + + // Handle confirm button - select current option + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + onModeSelected(mode); + return; + } + + // Handle navigation + const bool prevPressed = + inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); + const bool nextPressed = + inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + + if (prevPressed) { + selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; + updateRequired = true; + } else if (nextPressed) { + selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT; + updateRequired = true; + } +} + +void NetworkModeSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void NetworkModeSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR); + + // Draw menu items centered on screen + constexpr int itemHeight = 50; // Height for each menu item (including description) + const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10; + + for (int i = 0; i < MENU_ITEM_COUNT; i++) { + const int itemY = startY + i * itemHeight; + const bool isSelected = (i == selectedIndex); + + // Draw selection highlight (black fill) for selected item + if (isSelected) { + renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6); + } + + // Draw text: black=false (white text) when selected (on black background) + // black=true (black text) when not selected (on white background) + renderer.drawText(UI_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected); + renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); + } + + // Draw help text at bottom + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR); + + renderer.displayBuffer(); +} diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h new file mode 100644 index 00000000..90f4282b --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for network mode selection +enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; + +/** + * NetworkModeSelectionActivity presents the user with a choice: + * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) + * + * The onModeSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class NetworkModeSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onModeSelected; + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onModeSelected, + const std::function& onCancel) + : Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 10159aba..f14081f7 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -30,12 +30,22 @@ void CrossPointWebServer::begin() { return; } - if (WiFi.status() != WL_CONNECTED) { - Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis()); + // Check if we have a valid network connection (either STA connected or AP mode) + const wifi_mode_t wifiMode = WiFi.getMode(); + const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED); + const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running + + if (!isStaConnected && !isInApMode) { + Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, + WiFi.status()); return; } + // Store AP mode flag for later use (e.g., in handleStatus) + apMode = isInApMode; + Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); + Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); server.reset(new WebServer(port)); @@ -70,7 +80,9 @@ void CrossPointWebServer::begin() { running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); - Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str()); + // Show the correct IP based on network mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -141,10 +153,14 @@ void CrossPointWebServer::handleNotFound() const { } void CrossPointWebServer::handleStatus() const { + // Get correct IP based on AP vs STA mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + String json = "{"; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; - json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; - json += "\"rssi\":" + String(WiFi.RSSI()) + ","; + json += "\"ip\":\"" + ipAddr + "\","; + json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\","; + json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; json += "\"uptime\":" + String(millis() / 1000); json += "}"; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 327897fb..1be07b4a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -35,6 +35,7 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; bool running = false; + bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; // File scanning From 66ddb52103a7ccef8f0d04d872b6268d9224bd33 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 23 Dec 2025 12:16:42 +1100 Subject: [PATCH 27/33] Pin espressif32 platform version --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index a44dd476..42de32c0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ crosspoint_version = 0.8.1 default_envs = default [base] -platform = espressif32 +platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 framework = arduino monitor_speed = 115200 From 1107590b5676dbf8091be0d264a3eb6c8a1add98 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 23 Dec 2025 14:14:10 +1100 Subject: [PATCH 28/33] Standardize File handling with FsHelpers (#110) ## Summary * Standardize File handling with FsHelpers * Better central place to manage to logic of if files exist/open for reading/writing --- lib/Epub/Epub.cpp | 81 +++++-------- lib/Epub/Epub/FsHelpers.cpp | 36 ------ lib/Epub/Epub/FsHelpers.h | 6 - lib/Epub/Epub/Page.cpp | 36 +++--- lib/Epub/Epub/Page.h | 12 +- lib/Epub/Epub/Section.cpp | 50 ++++---- lib/Epub/Epub/blocks/TextBlock.cpp | 32 ++--- lib/Epub/Epub/blocks/TextBlock.h | 5 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 ++-- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 4 +- lib/FsHelpers/FsHelpers.cpp | 112 ++++++++++++++++++ lib/FsHelpers/FsHelpers.h | 14 +++ lib/Serialization/Serialization.h | 25 ++++ src/CrossPointSettings.cpp | 18 +-- src/CrossPointState.cpp | 17 ++- src/WifiCredentialStore.cpp | 20 +--- src/activities/boot_sleep/SleepActivity.cpp | 13 +- src/activities/reader/EpubReaderActivity.cpp | 24 ++-- src/main.cpp | 7 +- src/network/CrossPointWebServer.cpp | 4 +- 20 files changed, 315 insertions(+), 221 deletions(-) delete mode 100644 lib/Epub/Epub/FsHelpers.cpp delete mode 100644 lib/Epub/Epub/FsHelpers.h create mode 100644 lib/FsHelpers/FsHelpers.cpp create mode 100644 lib/FsHelpers/FsHelpers.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index d959cb79..010f000d 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,5 +1,6 @@ #include "Epub.h" +#include #include #include #include @@ -7,7 +8,6 @@ #include -#include "Epub/FsHelpers.h" #include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/TocNcxParser.h" @@ -95,10 +95,15 @@ bool Epub::parseTocNcxFile() { Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); const auto tmpNcxPath = getCachePath() + "/toc.ncx"; - File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); + File tempNcxFile; + if (!FsHelpers::openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); tempNcxFile.close(); - tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); + if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } const auto ncxSize = tempNcxFile.size(); TocNcxParser ncxParser(contentBasePath, ncxSize); @@ -235,16 +240,28 @@ bool Epub::generateCoverBmp() const { if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); - File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + File coverJpg; + if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } readItemContentsToStream(coverImageItem, coverJpg, 1024); coverJpg.close(); - coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); - File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + File coverBmp; + if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); coverJpg.close(); coverBmp.close(); - SD.remove((getCachePath() + "/.cover.jpg").c_str()); + SD.remove(coverJpgTempPath.c_str()); if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); @@ -259,45 +276,9 @@ bool Epub::generateCoverBmp() const { return false; } -std::string normalisePath(const std::string& path) { - std::vector components; - std::string component; - - for (const auto c : path) { - if (c == '/') { - if (!component.empty()) { - if (component == "..") { - if (!components.empty()) { - components.pop_back(); - } - } else { - components.push_back(component); - } - component.clear(); - } - } else { - component += c; - } - } - - if (!component.empty()) { - components.push_back(component); - } - - std::string result; - for (const auto& c : components) { - if (!result.empty()) { - result += "/"; - } - result += c; - } - - return result; -} - uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte); if (!content) { @@ -310,7 +291,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); return zip.readFileToStream(path.c_str(), out, chunkSize); } @@ -321,7 +302,7 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { } bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); return zip.getInflatedFileSize(path.c_str(), size); } @@ -349,18 +330,18 @@ std::string& Epub::getSpineItem(const int spineIndex) { return spine.at(spineIndex).second; } -EpubTocEntry& Epub::getTocItem(const int tocTndex) { +EpubTocEntry& Epub::getTocItem(const int tocIndex) { static EpubTocEntry emptyEntry = {}; if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return emptyEntry; } - if (tocTndex < 0 || tocTndex >= static_cast(toc.size())) { - Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); + if (tocIndex < 0 || tocIndex >= static_cast(toc.size())) { + Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); return toc.at(0); } - return toc.at(tocTndex); + return toc.at(tocIndex); } int Epub::getTocItemsCount() const { return toc.size(); } diff --git a/lib/Epub/Epub/FsHelpers.cpp b/lib/Epub/Epub/FsHelpers.cpp deleted file mode 100644 index 5287252a..00000000 --- a/lib/Epub/Epub/FsHelpers.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "FsHelpers.h" - -#include - -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); -} diff --git a/lib/Epub/Epub/FsHelpers.h b/lib/Epub/Epub/FsHelpers.h deleted file mode 100644 index bc5204b7..00000000 --- a/lib/Epub/Epub/FsHelpers.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -class FsHelpers { - public: - static bool removeDir(const char* path); -}; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 01bb3aca..b41dd3c4 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -9,21 +9,21 @@ constexpr uint8_t PAGE_FILE_VERSION = 3; void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } -void PageLine::serialize(std::ostream& os) { - serialization::writePod(os, xPos); - serialization::writePod(os, yPos); +void PageLine::serialize(File& file) { + serialization::writePod(file, xPos); + serialization::writePod(file, yPos); // serialize TextBlock pointed to by PageLine - block->serialize(os); + block->serialize(file); } -std::unique_ptr PageLine::deserialize(std::istream& is) { +std::unique_ptr PageLine::deserialize(File& file) { int16_t xPos; int16_t yPos; - serialization::readPod(is, xPos); - serialization::readPod(is, yPos); + serialization::readPod(file, xPos); + serialization::readPod(file, yPos); - auto tb = TextBlock::deserialize(is); + auto tb = TextBlock::deserialize(file); return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } @@ -33,22 +33,22 @@ void Page::render(GfxRenderer& renderer, const int fontId) const { } } -void Page::serialize(std::ostream& os) const { - serialization::writePod(os, PAGE_FILE_VERSION); +void Page::serialize(File& file) const { + serialization::writePod(file, PAGE_FILE_VERSION); const uint32_t count = elements.size(); - serialization::writePod(os, count); + serialization::writePod(file, count); for (const auto& el : elements) { // Only PageLine exists currently - serialization::writePod(os, static_cast(TAG_PageLine)); - el->serialize(os); + serialization::writePod(file, static_cast(TAG_PageLine)); + el->serialize(file); } } -std::unique_ptr Page::deserialize(std::istream& is) { +std::unique_ptr Page::deserialize(File& file) { uint8_t version; - serialization::readPod(is, version); + serialization::readPod(file, version); if (version != PAGE_FILE_VERSION) { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version); return nullptr; @@ -57,14 +57,14 @@ std::unique_ptr Page::deserialize(std::istream& is) { auto page = std::unique_ptr(new Page()); uint32_t count; - serialization::readPod(is, count); + serialization::readPod(file, count); for (uint32_t i = 0; i < count; i++) { uint8_t tag; - serialization::readPod(is, tag); + serialization::readPod(file, tag); if (tag == TAG_PageLine) { - auto pl = PageLine::deserialize(is); + auto pl = PageLine::deserialize(file); page->elements.push_back(std::move(pl)); } else { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 59333cea..10266534 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,4 +1,6 @@ #pragma once +#include + #include #include @@ -16,7 +18,7 @@ class PageElement { explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} virtual ~PageElement() = default; virtual void render(GfxRenderer& renderer, int fontId) = 0; - virtual void serialize(std::ostream& os) = 0; + virtual void serialize(File& file) = 0; }; // a line from a block element @@ -27,8 +29,8 @@ class PageLine final : public PageElement { PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} void render(GfxRenderer& renderer, int fontId) override; - void serialize(std::ostream& os) override; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) override; + static std::unique_ptr deserialize(File& file); }; class Page { @@ -36,6 +38,6 @@ class Page { // the list of block index and line numbers on this page std::vector> elements; void render(GfxRenderer& renderer, int fontId) const; - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7c9d241e..ec2993f0 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -1,11 +1,9 @@ #include "Section.h" +#include #include #include -#include - -#include "FsHelpers.h" #include "Page.h" #include "parsers/ChapterHtmlSlimParser.h" @@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 5; void Section::onPageComplete(std::unique_ptr page) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; - std::ofstream outputFile("/sd" + filePath); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", filePath, outputFile)) { + return; + } page->serialize(outputFile); outputFile.close(); @@ -28,7 +29,10 @@ void Section::onPageComplete(std::unique_ptr page) { void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) const { - std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { + return; + } serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, lineCompression); @@ -44,17 +48,12 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - if (!SD.exists(cachePath.c_str())) { - return false; - } - const auto sectionFilePath = cachePath + "/section.bin"; - if (!SD.exists(sectionFilePath.c_str())) { + File inputFile; + if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) { return false; } - std::ifstream inputFile(("/sd" + sectionFilePath).c_str()); - // Match parameters { uint8_t version; @@ -119,13 +118,13 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing) { const auto localPath = epub->getSpineItem(spineIndex); - // TODO: Should we get rid of this file all together? - // It currently saves us a bit of memory by allowing for all the inflation bits to be released - // before loading the XML parser const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; - File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); - bool success = epub->readItemContentsToStream(localPath, f, 1024); - f.close(); + File tmpHtml; + if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + return false; + } + bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); + tmpHtml.close(); if (!success) { Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); @@ -134,10 +133,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); - const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; - - ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, - marginBottom, marginLeft, extraParagraphSpacing, + ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, + marginLeft, extraParagraphSpacing, [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); success = visitor.parseAndBuildPages(); @@ -153,13 +150,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, } std::unique_ptr Section::loadPageFromSD() const { - const auto filePath = "/sd" + cachePath + "/page_" + std::to_string(currentPage) + ".bin"; - if (!SD.exists(filePath.c_str() + 3)) { - Serial.printf("[%lu] [SCT] Page file does not exist: %s\n", millis(), filePath.c_str()); + const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin"; + + File inputFile; + if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) { return nullptr; } - - std::ifstream inputFile(filePath); auto page = Page::deserialize(inputFile); inputFile.close(); return page; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index cc3cb60a..bb8b14e8 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -17,27 +17,27 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } } -void TextBlock::serialize(std::ostream& os) const { +void TextBlock::serialize(File& file) const { // words const uint32_t wc = words.size(); - serialization::writePod(os, wc); - for (const auto& w : words) serialization::writeString(os, w); + serialization::writePod(file, wc); + for (const auto& w : words) serialization::writeString(file, w); // wordXpos const uint32_t xc = wordXpos.size(); - serialization::writePod(os, xc); - for (auto x : wordXpos) serialization::writePod(os, x); + serialization::writePod(file, xc); + for (auto x : wordXpos) serialization::writePod(file, x); // wordStyles const uint32_t sc = wordStyles.size(); - serialization::writePod(os, sc); - for (auto s : wordStyles) serialization::writePod(os, s); + serialization::writePod(file, sc); + for (auto s : wordStyles) serialization::writePod(file, s); // style - serialization::writePod(os, style); + serialization::writePod(file, style); } -std::unique_ptr TextBlock::deserialize(std::istream& is) { +std::unique_ptr TextBlock::deserialize(File& file) { uint32_t wc, xc, sc; std::list words; std::list wordXpos; @@ -45,22 +45,22 @@ std::unique_ptr TextBlock::deserialize(std::istream& is) { BLOCK_STYLE style; // words - serialization::readPod(is, wc); + serialization::readPod(file, wc); words.resize(wc); - for (auto& w : words) serialization::readString(is, w); + for (auto& w : words) serialization::readString(file, w); // wordXpos - serialization::readPod(is, xc); + serialization::readPod(file, xc); wordXpos.resize(xc); - for (auto& x : wordXpos) serialization::readPod(is, x); + for (auto& x : wordXpos) serialization::readPod(file, x); // wordStyles - serialization::readPod(is, sc); + serialization::readPod(file, sc); wordStyles.resize(sc); - for (auto& s : wordStyles) serialization::readPod(is, s); + for (auto& s : wordStyles) serialization::readPod(file, s); // style - serialization::readPod(is, style); + serialization::readPod(file, style); return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 4b2b0319..46e320e3 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -35,6 +36,6 @@ class TextBlock final : public Block { // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index a6297070..4a9b86cc 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,5 +1,6 @@ #include "ChapterHtmlSlimParser.h" +#include #include #include #include @@ -214,9 +215,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - FILE* file = fopen(filepath, "r"); - if (!file) { - Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); + File file; + if (!FsHelpers::openFileForRead("EHP", filepath, file)) { XML_ParserFree(parser); return false; } @@ -233,23 +233,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - const size_t len = fread(buf, 1, 1024, file); + const size_t len = file.read(static_cast(buf), 1024); - if (ferror(file)) { + if (len == 0) { Serial.printf("[%lu] [EHP] File read error\n", millis()); XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - done = feof(file); + done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), @@ -258,7 +258,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } } while (!done); @@ -267,7 +267,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); // Process last page if there is still text if (currentTextBlock) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index f656b4a5..7f74602a 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -15,7 +15,7 @@ class GfxRenderer; #define MAX_WORD_SIZE 200 class ChapterHtmlSlimParser { - const char* filepath; + const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; int depth = 0; @@ -45,7 +45,7 @@ class ChapterHtmlSlimParser { static void XMLCALL endElement(void* userData, const XML_Char* name); public: - explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, + explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const std::function)>& completePageFn) diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp new file mode 100644 index 00000000..06f3dfe6 --- /dev/null +++ b/lib/FsHelpers/FsHelpers.cpp @@ -0,0 +1,112 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const char* path, File& file) { + if (!SD.exists(path)) { + return false; + } + + file = SD.open(path, FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForRead(const char* moduleName, const String& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const char* path, File& file) { + file = SD.open(path, FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const String& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +bool FsHelpers::removeDir(const char* path) { + // 1. Open the directory + File dir = SD.open(path); + if (!dir) { + return false; + } + if (!dir.isDirectory()) { + return false; + } + + File file = dir.openNextFile(); + while (file) { + String filePath = path; + if (!filePath.endsWith("/")) { + filePath += "/"; + } + filePath += file.name(); + + if (file.isDirectory()) { + if (!removeDir(filePath.c_str())) { + return false; + } + } else { + if (!SD.remove(filePath.c_str())) { + return false; + } + } + file = dir.openNextFile(); + } + + return SD.rmdir(path); +} + +std::string FsHelpers::normalisePath(const std::string& path) { + std::vector components; + std::string component; + + for (const auto c : path) { + if (c == '/') { + if (!component.empty()) { + if (component == "..") { + if (!components.empty()) { + components.pop_back(); + } + } else { + components.push_back(component); + } + component.clear(); + } + } else { + component += c; + } + } + + if (!component.empty()) { + components.push_back(component); + } + + std::string result; + for (const auto& c : components) { + if (!result.empty()) { + result += "/"; + } + result += c; + } + + return result; +} diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h new file mode 100644 index 00000000..0dff145d --- /dev/null +++ b/lib/FsHelpers/FsHelpers.h @@ -0,0 +1,14 @@ +#pragma once +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const char* path, File& file); + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForRead(const char* moduleName, const String& path, File& file); + static bool openFileForWrite(const char* moduleName, const char* path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const String& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/Serialization/Serialization.h b/lib/Serialization/Serialization.h index 20eb4a4b..e6bcbf29 100644 --- a/lib/Serialization/Serialization.h +++ b/lib/Serialization/Serialization.h @@ -1,4 +1,6 @@ #pragma once +#include + #include namespace serialization { @@ -7,21 +9,44 @@ static void writePod(std::ostream& os, const T& value) { os.write(reinterpret_cast(&value), sizeof(T)); } +template +static void writePod(File& file, const T& value) { + file.write(reinterpret_cast(&value), sizeof(T)); +} + template static void readPod(std::istream& is, T& value) { is.read(reinterpret_cast(&value), sizeof(T)); } +template +static void readPod(File& file, T& value) { + file.read(reinterpret_cast(&value), sizeof(T)); +} + static void writeString(std::ostream& os, const std::string& s) { const uint32_t len = s.size(); writePod(os, len); os.write(s.data(), len); } +static void writeString(File& file, const std::string& s) { + const uint32_t len = s.size(); + writePod(file, len); + file.write(reinterpret_cast(s.data()), len); +} + static void readString(std::istream& is, std::string& s) { uint32_t len; readPod(is, len); s.resize(len); is.read(&s[0], len); } + +static void readString(File& file, std::string& s) { + uint32_t len; + readPod(file, len); + s.resize(len); + file.read(reinterpret_cast(&s[0]), len); +} } // namespace serialization diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index fe5e2a07..467ee9ca 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -1,26 +1,28 @@ #include "CrossPointSettings.h" +#include #include #include #include -#include -#include - // Initialize the static instance CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_COUNT = 3; -constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin"; +constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace bool CrossPointSettings::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream outputFile(SETTINGS_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", SETTINGS_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); serialization::writePod(outputFile, sleepScreen); @@ -33,13 +35,11 @@ bool CrossPointSettings::saveToFile() const { } bool CrossPointSettings::loadFromFile() { - if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis()); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) { return false; } - std::ifstream inputFile(SETTINGS_FILE); - uint8_t version; serialization::readPod(inputFile, version); if (version != SETTINGS_FILE_VERSION) { diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index dd96593f..9010822d 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -1,20 +1,22 @@ #include "CrossPointState.h" +#include #include -#include #include -#include - namespace { constexpr uint8_t STATE_FILE_VERSION = 1; -constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; +constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace CrossPointState CrossPointState::instance; bool CrossPointState::saveToFile() const { - std::ofstream outputFile(STATE_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", STATE_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); outputFile.close(); @@ -22,7 +24,10 @@ bool CrossPointState::saveToFile() const { } bool CrossPointState::loadFromFile() { - std::ifstream inputFile(STATE_FILE); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) { + return false; + } uint8_t version; serialization::readPod(inputFile, version); diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 7df9e2fe..856098f2 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -1,11 +1,10 @@ #include "WifiCredentialStore.h" +#include #include #include #include -#include - // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; @@ -14,7 +13,7 @@ namespace { constexpr uint8_t WIFI_FILE_VERSION = 1; // WiFi credentials file path -constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; +constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; // Obfuscation key - "CrossPoint" in ASCII // This is NOT cryptographic security, just prevents casual file reading @@ -33,9 +32,8 @@ bool WifiCredentialStore::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis()); + File file; + if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) { return false; } @@ -62,14 +60,8 @@ bool WifiCredentialStore::saveToFile() const { } bool WifiCredentialStore::loadFromFile() { - if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); - return false; - } - - std::ifstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis()); + File file; + if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) { return false; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index ca72aeb1..4bc70f57 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,6 +1,7 @@ #include "SleepActivity.h" #include +#include #include #include @@ -76,8 +77,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Generate a random number between 1 and numFiles const auto randomFileIndex = random(numFiles); const auto filename = "/sleep/" + files[randomFileIndex]; - auto file = SD.open(filename.c_str()); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file); @@ -93,8 +94,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. - auto file = SD.open("/sleep.bmp"); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); @@ -186,8 +187,8 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fd9a8135..7843f5bb 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,9 +1,9 @@ #include "EpubReaderActivity.h" #include +#include #include #include -#include #include "Battery.h" #include "CrossPointSettings.h" @@ -37,8 +37,8 @@ void EpubReaderActivity::onEnter() { epub->setupCacheDir(); - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); - if (f) { + File f; + if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { currentSpineIndex = data[0] + (data[1] << 8); @@ -282,14 +282,16 @@ void EpubReaderActivity::renderScreen() { Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); - uint8_t data[4]; - data[0] = currentSpineIndex & 0xFF; - data[1] = (currentSpineIndex >> 8) & 0xFF; - data[2] = section->currentPage & 0xFF; - data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); - f.close(); + File f; + if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentSpineIndex & 0xFF; + data[1] = (currentSpineIndex >> 8) & 0xFF; + data[2] = section->currentPage & 0xFF; + data[3] = (section->currentPage >> 8) & 0xFF; + f.write(data, 4); + f.close(); + } } void EpubReaderActivity::renderContents(std::unique_ptr page) { diff --git a/src/main.cpp b/src/main.cpp index cf74479a..03f51508 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -160,7 +160,12 @@ void onGoHome() { void setup() { t1 = millis(); - Serial.begin(115200); + + // Only start serial if USB connected + pinMode(UART0_RXD, INPUT); + if (digitalRead(UART0_RXD) == HIGH) { + Serial.begin(115200); + } Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index f14081f7..041273f0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,6 +1,7 @@ #include "CrossPointWebServer.h" #include +#include #include #include @@ -339,8 +340,7 @@ void CrossPointWebServer::handleUpload() const { } // Open file for writing - uploadFile = SD.open(filePath.c_str(), FILE_WRITE); - if (!uploadFile) { + if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; From 27035b2b91dabd403b8fa13f5101866d0df70e6d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:21:41 +1100 Subject: [PATCH 29/33] Handle 16x16 MCU blocks in JPEG decoding (#120) ## Summary * Handle 16x16 MCU blocks in JPEG decoding * We were only correctly handling 8x8 blocks, which means that we did not correctly support a lot of JPGs leading to an interlacing style on the images ## Additional Context * Fixes https://github.com/daveallie/crosspoint-reader/issues/118 --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b48d70a..c2c049a7 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -182,6 +182,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { } // Process MCU block into MCU row buffer + // MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks: + // Block layout for 16x16 MCU: [0, 64] (top row of blocks) + // [128, 192] (bottom row of blocks) for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { const int pixelX = mcuX * mcuPixelWidth + blockX; @@ -191,16 +194,27 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { continue; } + // Calculate which 8x8 block and position within that block + const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU + const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU + const int pixelInBlockX = blockX % 8; + const int pixelInBlockY = blockY % 8; + + // Calculate byte offset: each 8x8 block is 64 bytes + // Blocks are arranged: [0, 64], [128, 192] + const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64; + const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX; + // Get grayscale value uint8_t gray; if (imageInfo.m_comps == 1) { // Grayscale image - gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + gray = imageInfo.m_pMCUBufR[mcuIndex]; } else { // RGB image - convert to grayscale - const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; - const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; - const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; + const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex]; + const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex]; + const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex]; // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B // Using integer approximation: (30*R + 59*G + 11*B) / 100 gray = (r * 30 + g * 59 + b * 11) / 100; From 2771579007656f78fec756987679abb98cc1ded8 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:17 +1100 Subject: [PATCH 30/33] Add support for blockquote, strong, and em tags (#121) ## Summary * Add support for blockquote, strong, and em tags --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 4a9b86cc..766e5ca6 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -11,13 +11,13 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); -const char* BLOCK_TAGS[] = {"p", "li", "div", "br"}; +const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); -const char* BOLD_TAGS[] = {"b"}; +const char* BOLD_TAGS[] = {"b", "strong"}; constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); -const char* ITALIC_TAGS[] = {"i"}; +const char* ITALIC_TAGS[] = {"i", "em"}; constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; From ea0abaf3513beeb3c21c1105d91182a7a430f3fa Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:21 +1100 Subject: [PATCH 31/33] Prevent SD card error causing boot loop (#122) ## Summary * Prevent SD card error causing boot loop * We need the screen and fonts to be initialized to show the full screen error message * Prior to this change, trying to render the font would crash the firmware and boot loop it --- src/main.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 03f51508..83a33cc7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -158,6 +158,15 @@ void onGoHome() { enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); } +void setupDisplayAndFonts() { + einkDisplay.begin(); + Serial.printf("[%lu] [ ] Display initialized\n", millis()); + renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); + renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); + renderer.insertFont(SMALL_FONT_ID, smallFontFamily); + Serial.printf("[%lu] [ ] Fonts setup\n", millis()); +} + void setup() { t1 = millis(); @@ -179,6 +188,7 @@ void setup() { // SD Card Initialization if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); return; @@ -189,14 +199,7 @@ void setup() { // verify power button press duration after we've read settings. verifyWakeupLongPress(); - // Initialize display - einkDisplay.begin(); - Serial.printf("[%lu] [ ] Display initialized\n", millis()); - - renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); - renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); - renderer.insertFont(SMALL_FONT_ID, smallFontFamily); - Serial.printf("[%lu] [ ] Fonts setup\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new BootActivity(renderer, inputManager)); From b6bc1f7ed365fed9a1fa655fbafacbe43047ff7e Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:36:13 +1100 Subject: [PATCH 32/33] New book.bin spine and table of contents cache (#104) ## Summary * Use single unified cache file for book spine, table of contents, and core metadata (title, author, cover image) * Use new temp item store file in OPF parsing to store items to be rescaned when parsing spine * This avoids us holding these items in memory * Use new toc.bin.tmp and spine.bin.tmp to build out partial toc / spine data as part of parsing content.opf and the NCX file * These files are re-read multiple times to ultimately build book.bin ## Additional Context * Spec for file format included below as an image * This should help with: * #10 * #60 * #99 --- lib/Epub/Epub.cpp | 254 ++++++++------ lib/Epub/Epub.h | 29 +- lib/Epub/Epub/BookMetadataCache.cpp | 326 ++++++++++++++++++ lib/Epub/Epub/BookMetadataCache.h | 87 +++++ lib/Epub/Epub/EpubTocEntry.h | 10 - lib/Epub/Epub/FsHelpers.cpp | 92 +++++ lib/Epub/Epub/FsHelpers.h | 12 + lib/Epub/Epub/Section.cpp | 3 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 60 +++- lib/Epub/Epub/parsers/ContentOpfParser.h | 17 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 8 +- lib/Epub/Epub/parsers/TocNcxParser.h | 12 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- .../EpubReaderChapterSelectionActivity.cpp | 2 +- src/main.cpp | 3 +- 15 files changed, 748 insertions(+), 169 deletions(-) create mode 100644 lib/Epub/Epub/BookMetadataCache.cpp create mode 100644 lib/Epub/Epub/BookMetadataCache.h delete mode 100644 lib/Epub/Epub/EpubTocEntry.h create mode 100644 lib/Epub/Epub/FsHelpers.cpp create mode 100644 lib/Epub/Epub/FsHelpers.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 010f000d..b48d7ea3 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -6,8 +6,6 @@ #include #include -#include - #include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/TocNcxParser.h" @@ -44,7 +42,15 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const { return true; } -bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { +bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { + std::string contentOpfFilePath; + if (!findContentOpfFile(&contentOpfFilePath)) { + Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + return false; + } + + contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); + Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); size_t contentOpfSize; @@ -53,7 +59,9 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { return false; } - ContentOpfParser opfParser(getBasePath(), contentOpfSize); + ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get()); + Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), + ESP.getHeapSize(), ESP.getMinFreeHeap()); if (!opfParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); @@ -66,26 +74,20 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { } // Grab data from opfParser into epub - title = opfParser.title; - if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) { - coverImageItem = opfParser.items.at(opfParser.coverItemId); - } + bookMetadata.title = opfParser.title; + // TODO: Parse author + bookMetadata.author = ""; + bookMetadata.coverItemHref = opfParser.coverItemHref; if (!opfParser.tocNcxPath.empty()) { tocNcxItem = opfParser.tocNcxPath; } - for (auto& spineRef : opfParser.spineRefs) { - if (opfParser.items.count(spineRef)) { - spine.emplace_back(spineRef, opfParser.items.at(spineRef)); - } - } - Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); return true; } -bool Epub::parseTocNcxFile() { +bool Epub::parseTocNcxFile() const { // the ncx file should have been specified in the content.opf file if (tocNcxItem.empty()) { Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); @@ -106,7 +108,7 @@ bool Epub::parseTocNcxFile() { } const auto ncxSize = tempNcxFile.size(); - TocNcxParser ncxParser(contentBasePath, ncxSize); + TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get()); if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); @@ -135,9 +137,7 @@ bool Epub::parseTocNcxFile() { tempNcxFile.close(); SD.remove(tmpNcxPath.c_str()); - this->toc = std::move(ncxParser.toc); - - Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); + Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis()); return true; } @@ -145,48 +145,79 @@ bool Epub::parseTocNcxFile() { bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); - std::string contentOpfFilePath; - if (!findContentOpfFile(&contentOpfFilePath)) { - Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + // Initialize spine/TOC cache + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + + // Try to load existing cache first + if (bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; + } + + // Cache doesn't exist or is invalid, build it + Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); + setupCacheDir(); + + // Begin building cache - stream entries to disk immediately + if (!bookMetadataCache->beginWrite()) { + Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); return false; } - Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str()); - - contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); - - if (!parseContentOpf(contentOpfFilePath)) { + // OPF Pass + BookMetadataCache::BookMetadata bookMetadata; + if (!bookMetadataCache->beginContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis()); + return false; + } + if (!parseContentOpf(bookMetadata)) { Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); return false; } + if (!bookMetadataCache->endContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis()); + return false; + } + // TOC Pass + if (!bookMetadataCache->beginTocPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); + return false; + } if (!parseTocNcxFile()) { Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); return false; } - - initializeSpineItemSizes(); - Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); - - return true; -} - -void Epub::initializeSpineItemSizes() { - Serial.printf("[%lu] [EBP] Calculating book size\n", millis()); - - const size_t spineItemsCount = getSpineItemsCount(); - size_t cumSpineItemSize = 0; - const ZipFile zip("/sd" + filepath); - - for (size_t i = 0; i < spineItemsCount; i++) { - std::string spineItem = getSpineItem(i); - size_t s = 0; - getItemSize(zip, spineItem, &s); - cumSpineItemSize += s; - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); + if (!bookMetadataCache->endTocPass()) { + Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); + return false; } - Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize); + // Close the cache files + if (!bookMetadataCache->endWrite()) { + Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); + return false; + } + + // Build final book.bin + if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) { + Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); + return false; + } + + if (!bookMetadataCache->cleanupTmpFiles()) { + Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis()); + } + + // Reload the cache from disk so it's in the correct state + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + if (!bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); + return false; + } + + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; } bool Epub::clearCache() const { @@ -222,7 +253,14 @@ const std::string& Epub::getCachePath() const { return cachePath; } const std::string& Epub::getPath() const { return filepath; } -const std::string& Epub::getTitle() const { return title; } +const std::string& Epub::getTitle() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + + return bookMetadataCache->coreMetadata.title; +} std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } @@ -232,13 +270,19 @@ bool Epub::generateCoverBmp() const { return true; } - if (coverImageItem.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { Serial.printf("[%lu] [EBP] No known cover image\n", millis()); return false; } - if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || - coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; @@ -246,7 +290,7 @@ bool Epub::generateCoverBmp() const { if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { return false; } - readItemContentsToStream(coverImageItem, coverJpg, 1024); + readItemContentsToStream(coverImageHref, coverJpg, 1024); coverJpg.close(); if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { @@ -276,7 +320,7 @@ bool Epub::generateCoverBmp() const { return false; } -uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { +uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { const ZipFile zip("/sd" + filepath); const std::string path = FsHelpers::normalisePath(itemHref); @@ -306,99 +350,89 @@ bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* return zip.getInflatedFileSize(path.c_str(), size); } -int Epub::getSpineItemsCount() const { return spine.size(); } - -size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= static_cast(cumulativeSpineItemSize.size())) { - Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); +int Epub::getSpineItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { return 0; } - return cumulativeSpineItemSize.at(spineIndex); + return bookMetadataCache->getSpineCount(); } -std::string& Epub::getSpineItem(const int spineIndex) { - static std::string emptyString; - if (spine.empty()) { - Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); - return emptyString; +size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; } + +BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); + return {}; } - if (spineIndex < 0 || spineIndex >= static_cast(spine.size())) { + + if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); - return spine.at(0).second; + return bookMetadataCache->getSpineEntry(0); } - return spine.at(spineIndex).second; + return bookMetadataCache->getSpineEntry(spineIndex); } -EpubTocEntry& Epub::getTocItem(const int tocIndex) { - static EpubTocEntry emptyEntry = {}; - if (toc.empty()) { - Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); - return emptyEntry; +BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); + return {}; } - if (tocIndex < 0 || tocIndex >= static_cast(toc.size())) { + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); - return toc.at(0); + return {}; } - return toc.at(tocIndex); + return bookMetadataCache->getTocEntry(tocIndex); } -int Epub::getTocItemsCount() const { return toc.size(); } +int Epub::getTocItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return 0; + } + + return bookMetadataCache->getTocCount(); +} // work out the section index for a toc index int Epub::getSpineIndexForTocIndex(const int tocIndex) const { - if (tocIndex < 0 || tocIndex >= toc.size()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); + return 0; + } + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); return 0; } - // the toc entry should have an href that matches the spine item - // so we can find the spine index by looking for the href - for (int i = 0; i < spine.size(); i++) { - if (spine[i].second == toc[tocIndex].href) { - return i; - } + const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex; + if (spineIndex < 0) { + Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); + return 0; } - Serial.printf("[%lu] [EBP] Section not found\n", millis()); - // not found - default to the start of the book - return 0; + return spineIndex; } -int Epub::getTocIndexForSpineIndex(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= spine.size()) { - Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex); - return -1; - } - - // the toc entry should have an href that matches the spine item - // so we can find the toc index by looking for the href - for (int i = 0; i < toc.size(); i++) { - if (toc[i].href == spine[spineIndex].second) { - return i; - } - } - - Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); - return -1; -} +int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; } size_t Epub::getBookSize() const { - if (spine.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) { return 0; } return getCumulativeSpineItemSize(getSpineItemsCount() - 1); } // Calculate progress in book -uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) { - size_t bookSize = getBookSize(); +uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { + const size_t bookSize = getBookSize(); if (bookSize == 0) { return 0; } - size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; - size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; - size_t sectionProgSize = currentSpineRead * curChapterSize; + const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; + const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; + const size_t sectionProgSize = currentSpineRead * curChapterSize; return round(static_cast(prevChapterSize + sectionProgSize) / bookSize * 100.0); } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 381379c5..acdd32c8 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -1,38 +1,29 @@ #pragma once -#include +#include #include #include #include -#include "Epub/EpubTocEntry.h" +#include "Epub/BookMetadataCache.h" class ZipFile; class Epub { - // the title read from the EPUB meta data - std::string title; - // the cover image - std::string coverImageItem; // the ncx file std::string tocNcxItem; // where is the EPUBfile? std::string filepath; - // the spine of the EPUB file - std::vector> spine; - // the file size of the spine items (proxy to book progress) - std::vector cumulativeSpineItemSize; - // the toc of the EPUB file - std::vector toc; // the base path for items in the EPUB file std::string contentBasePath; // Uniq cache key based on filepath std::string cachePath; + // Spine and TOC cache + std::unique_ptr bookMetadataCache; bool findContentOpfFile(std::string* contentOpfFile) const; - bool parseContentOpf(const std::string& contentOpfFilePath); - bool parseTocNcxFile(); - void initializeSpineItemSizes(); + bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); + bool parseTocNcxFile() const; static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); public: @@ -54,14 +45,14 @@ class Epub { bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool getItemSize(const std::string& itemHref, size_t* size) const; - std::string& getSpineItem(int spineIndex); + BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const; + BookMetadataCache::TocEntry getTocItem(int tocIndex) const; int getSpineItemsCount() const; - size_t getCumulativeSpineItemSize(const int spineIndex) const; - EpubTocEntry& getTocItem(int tocIndex); int getTocItemsCount() const; int getSpineIndexForTocIndex(int tocIndex) const; int getTocIndexForSpineIndex(int spineIndex) const; + size_t getCumulativeSpineItemSize(int spineIndex) const; size_t getBookSize() const; - uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead); + uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const; }; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp new file mode 100644 index 00000000..3cef851a --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -0,0 +1,326 @@ +#include "BookMetadataCache.h" + +#include +#include +#include +#include + +#include + +#include "FsHelpers.h" + +namespace { +constexpr uint8_t BOOK_CACHE_VERSION = 1; +constexpr char bookBinFile[] = "/book.bin"; +constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; +constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; +} // namespace + +/* ============= WRITING / BUILDING FUNCTIONS ================ */ + +bool BookMetadataCache::beginWrite() { + buildMode = true; + spineCount = 0; + tocCount = 0; + Serial.printf("[%lu] [BMC] Entering write mode\n", millis()); + return true; +} + +bool BookMetadataCache::beginContentOpfPass() { + Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis()); + + // Open spine file for writing + return FsHelpers::openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile); +} + +bool BookMetadataCache::endContentOpfPass() { + spineFile.close(); + return true; +} + +bool BookMetadataCache::beginTocPass() { + Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis()); + + // Open spine file for reading + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + return false; + } + if (!FsHelpers::openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { + spineFile.close(); + return false; + } + return true; +} + +bool BookMetadataCache::endTocPass() { + tocFile.close(); + spineFile.close(); + return true; +} + +bool BookMetadataCache::endWrite() { + if (!buildMode) { + Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis()); + return false; + } + + buildMode = false; + Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) { + // Open all three files, writing to meta, reading from spine and toc + if (!FsHelpers::openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + bookFile.close(); + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { + bookFile.close(); + spineFile.close(); + return false; + } + + constexpr size_t headerASize = + sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(size_t) + sizeof(spineCount) + sizeof(tocCount); + const size_t metadataSize = + metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3; + const size_t lutSize = sizeof(size_t) * spineCount + sizeof(size_t) * tocCount; + const size_t lutOffset = headerASize + metadataSize; + + // Header A + serialization::writePod(bookFile, BOOK_CACHE_VERSION); + serialization::writePod(bookFile, lutOffset); + serialization::writePod(bookFile, spineCount); + serialization::writePod(bookFile, tocCount); + // Metadata + serialization::writeString(bookFile, metadata.title); + serialization::writeString(bookFile, metadata.author); + serialization::writeString(bookFile, metadata.coverItemHref); + + // Loop through spine entries, writing LUT positions + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto pos = spineFile.position(); + auto spineEntry = readSpineEntry(spineFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize); + } + + // Loop through toc entries, writing LUT positions + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto pos = tocFile.position(); + auto tocEntry = readTocEntry(tocFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize + spineFile.position()); + } + + // LUTs complete + // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin + + const ZipFile zip("/sd" + epubPath); + size_t cumSize = 0; + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + + tocFile.seek(0); + for (int j = 0; j < tocCount; j++) { + auto tocEntry = readTocEntry(tocFile); + if (tocEntry.spineIndex == i) { + spineEntry.tocIndex = j; + break; + } + } + + // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs + // Logging here is for debugging + if (spineEntry.tocIndex == -1) { + Serial.printf("[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s\n", millis(), i, + spineEntry.href.c_str()); + } + + // Calculate size for cumulative size + size_t itemSize = 0; + const std::string path = FsHelpers::normalisePath(spineEntry.href); + if (zip.getInflatedFileSize(path.c_str(), &itemSize)) { + cumSize += itemSize; + spineEntry.cumulativeSize = cumSize; + } else { + Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); + } + + // Write out spine data to book.bin + writeSpineEntry(bookFile, spineEntry); + } + + // Loop through toc entries from toc file writing to book.bin + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto tocEntry = readTocEntry(tocFile); + writeTocEntry(bookFile, tocEntry); + } + + bookFile.close(); + spineFile.close(); + tocFile.close(); + + Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis()); + return true; +} + +bool BookMetadataCache::cleanupTmpFiles() const { + if (SD.exists((cachePath + tmpSpineBinFile).c_str())) { + SD.remove((cachePath + tmpSpineBinFile).c_str()); + } + if (SD.exists((cachePath + tmpTocBinFile).c_str())) { + SD.remove((cachePath + tmpTocBinFile).c_str()); + } + return true; +} + +size_t BookMetadataCache::writeSpineEntry(File& file, const SpineEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.href); + serialization::writePod(file, entry.cumulativeSize); + serialization::writePod(file, entry.tocIndex); + return pos; +} + +size_t BookMetadataCache::writeTocEntry(File& file, const TocEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.title); + serialization::writeString(file, entry.href); + serialization::writeString(file, entry.anchor); + serialization::writePod(file, entry.level); + serialization::writePod(file, entry.spineIndex); + return pos; +} + +// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called +// this is because in this function we're marking positions of the items +void BookMetadataCache::createSpineEntry(const std::string& href) { + if (!buildMode || !spineFile) { + Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis()); + return; + } + + const SpineEntry entry(href, 0, -1); + writeSpineEntry(spineFile, entry); + spineCount++; +} + +void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, + const uint8_t level) { + if (!buildMode || !tocFile || !spineFile) { + Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis()); + return; + } + + int spineIndex = -1; + // find spine index + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + if (spineEntry.href == href) { + spineIndex = i; + break; + } + } + + if (spineIndex == -1) { + Serial.printf("[%lu] [BMC] addTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); + } + + const TocEntry entry(title, href, anchor, level, spineIndex); + writeTocEntry(tocFile, entry); + tocCount++; +} + +/* ============= READING / LOADING FUNCTIONS ================ */ + +bool BookMetadataCache::load() { + if (!FsHelpers::openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + uint8_t version; + serialization::readPod(bookFile, version); + if (version != BOOK_CACHE_VERSION) { + Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version); + bookFile.close(); + return false; + } + + serialization::readPod(bookFile, lutOffset); + serialization::readPod(bookFile, spineCount); + serialization::readPod(bookFile, tocCount); + + serialization::readString(bookFile, coreMetadata.title); + serialization::readString(bookFile, coreMetadata.author); + serialization::readString(bookFile, coreMetadata.coverItemHref); + + loaded = true; + Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(spineCount)) { + Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to spine LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * index); + size_t spineEntryPos; + serialization::readPod(bookFile, spineEntryPos); + bookFile.seek(spineEntryPos); + return readSpineEntry(bookFile); +} + +BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(tocCount)) { + Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to TOC LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * spineCount + sizeof(size_t) * index); + size_t tocEntryPos; + serialization::readPod(bookFile, tocEntryPos); + bookFile.seek(tocEntryPos); + return readTocEntry(bookFile); +} + +BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(File& file) const { + SpineEntry entry; + serialization::readString(file, entry.href); + serialization::readPod(file, entry.cumulativeSize); + serialization::readPod(file, entry.tocIndex); + return entry; +} + +BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(File& file) const { + TocEntry entry; + serialization::readString(file, entry.title); + serialization::readString(file, entry.href); + serialization::readString(file, entry.anchor); + serialization::readPod(file, entry.level); + serialization::readPod(file, entry.spineIndex); + return entry; +} diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h new file mode 100644 index 00000000..7f9f419c --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include + +class BookMetadataCache { + public: + struct BookMetadata { + std::string title; + std::string author; + std::string coverItemHref; + }; + + struct SpineEntry { + std::string href; + size_t cumulativeSize; + int16_t tocIndex; + + SpineEntry() : cumulativeSize(0), tocIndex(-1) {} + SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex) + : href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {} + }; + + struct TocEntry { + std::string title; + std::string href; + std::string anchor; + uint8_t level; + int16_t spineIndex; + + TocEntry() : level(0), spineIndex(-1) {} + TocEntry(std::string title, std::string href, std::string anchor, const uint8_t level, const int16_t spineIndex) + : title(std::move(title)), + href(std::move(href)), + anchor(std::move(anchor)), + level(level), + spineIndex(spineIndex) {} + }; + + private: + std::string cachePath; + size_t lutOffset; + uint16_t spineCount; + uint16_t tocCount; + bool loaded; + bool buildMode; + + File bookFile; + // Temp file handles during build + File spineFile; + File tocFile; + + size_t writeSpineEntry(File& file, const SpineEntry& entry) const; + size_t writeTocEntry(File& file, const TocEntry& entry) const; + SpineEntry readSpineEntry(File& file) const; + TocEntry readTocEntry(File& file) const; + + public: + BookMetadata coreMetadata; + + explicit BookMetadataCache(std::string cachePath) + : cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} + ~BookMetadataCache() = default; + + // Building phase (stream to disk immediately) + bool beginWrite(); + bool beginContentOpfPass(); + void createSpineEntry(const std::string& href); + bool endContentOpfPass(); + bool beginTocPass(); + void createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level); + bool endTocPass(); + bool endWrite(); + bool cleanupTmpFiles() const; + + // Post-processing to update mappings and sizes + bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata); + + // Reading phase (read mode) + bool load(); + SpineEntry getSpineEntry(int index); + TocEntry getTocEntry(int index); + int getSpineCount() const { return spineCount; } + int getTocCount() const { return tocCount; } + bool isLoaded() const { return loaded; } +}; diff --git a/lib/Epub/Epub/EpubTocEntry.h b/lib/Epub/Epub/EpubTocEntry.h deleted file mode 100644 index 94f0c90f..00000000 --- a/lib/Epub/Epub/EpubTocEntry.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include - -struct EpubTocEntry { - std::string title; - std::string href; - std::string anchor; - uint8_t level; -}; diff --git a/lib/Epub/Epub/FsHelpers.cpp b/lib/Epub/Epub/FsHelpers.cpp new file mode 100644 index 00000000..743ac59b --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.cpp @@ -0,0 +1,92 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::removeDir(const char* path) { + // 1. Open the directory + File dir = SD.open(path); + if (!dir) { + return false; + } + if (!dir.isDirectory()) { + return false; + } + + File file = dir.openNextFile(); + while (file) { + String filePath = path; + if (!filePath.endsWith("/")) { + filePath += "/"; + } + filePath += file.name(); + + if (file.isDirectory()) { + if (!removeDir(filePath.c_str())) { + return false; + } + } else { + if (!SD.remove(filePath.c_str())) { + return false; + } + } + file = dir.openNextFile(); + } + + return SD.rmdir(path); +} + +std::string FsHelpers::normalisePath(const std::string& path) { + std::vector components; + std::string component; + + for (const auto c : path) { + if (c == '/') { + if (!component.empty()) { + if (component == "..") { + if (!components.empty()) { + components.pop_back(); + } + } else { + components.push_back(component); + } + component.clear(); + } + } else { + component += c; + } + } + + if (!component.empty()) { + components.push_back(component); + } + + std::string result; + for (const auto& c : components) { + if (!result.empty()) { + result += "/"; + } + result += c; + } + + return result; +} diff --git a/lib/Epub/Epub/FsHelpers.h b/lib/Epub/Epub/FsHelpers.h new file mode 100644 index 00000000..193db65f --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.h @@ -0,0 +1,12 @@ +#pragma once +#include + +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ec2993f0..5323a7a5 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -116,8 +116,7 @@ bool Section::clearCache() const { bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - const auto localPath = epub->getSpineItem(spineIndex); - + const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; File tmpHtml; if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 4d3d776f..3cc64014 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -1,11 +1,16 @@ #include "ContentOpfParser.h" +#include #include +#include #include +#include "../BookMetadataCache.h" + namespace { constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; -} +constexpr char itemCacheFile[] = "/.items.bin"; +} // namespace bool ContentOpfParser::setup() { parser = XML_ParserCreate(nullptr); @@ -28,6 +33,12 @@ ContentOpfParser::~ContentOpfParser() { XML_ParserFree(parser); parser = nullptr; } + if (tempItemStore) { + tempItemStore.close(); + } + if (SD.exists((cachePath + itemCacheFile).c_str())) { + SD.remove((cachePath + itemCacheFile).c_str()); + } } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } @@ -94,11 +105,21 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_MANIFEST; + if (!FsHelpers::openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n", + millis()); + } return; } if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { self->state = IN_SPINE; + if (!FsHelpers::openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n", + millis()); + } return; } @@ -135,7 +156,13 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } } - self->items[itemId] = href; + // Write items down to SD card + serialization::writeString(self->tempItemStore, itemId); + serialization::writeString(self->tempItemStore, href); + + if (itemId == self->coverItemId) { + self->coverItemHref = href; + } if (mediaType == MEDIA_TYPE_NCX) { if (self->tocNcxPath.empty()) { @@ -148,14 +175,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { - for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "idref") == 0) { - self->spineRefs.emplace_back(atts[i + 1]); - break; + // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec) + // Only run the spine parsing if there's a cache to add it to + if (self->cache) { + if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "idref") == 0) { + const std::string idref = atts[i + 1]; + // Resolve the idref to href using items map + self->tempItemStore.seek(0); + std::string itemId; + std::string href; + while (self->tempItemStore.available()) { + serialization::readString(self->tempItemStore, itemId); + serialization::readString(self->tempItemStore, href); + if (itemId == idref) { + self->cache->createSpineEntry(href); + break; + } + } + } } + return; } - return; } } @@ -174,11 +216,13 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { self->state = IN_PACKAGE; + self->tempItemStore.close(); return; } if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_PACKAGE; + self->tempItemStore.close(); return; } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index a3070fcc..5415de67 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -1,11 +1,11 @@ #pragma once #include -#include - #include "Epub.h" #include "expat.h" +class BookMetadataCache; + class ContentOpfParser final : public Print { enum ParserState { START, @@ -16,10 +16,14 @@ class ContentOpfParser final : public Print { IN_SPINE, }; + const std::string& cachePath; const std::string& baseContentPath; size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; + File tempItemStore; + std::string coverItemId; static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void characterData(void* userData, const XML_Char* s, int len); @@ -28,12 +32,11 @@ class ContentOpfParser final : public Print { public: std::string title; std::string tocNcxPath; - std::string coverItemId; - std::map items; - std::vector spineRefs; + std::string coverItemHref; - explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize, + BookMetadataCache* cache) + : cachePath(cachePath), baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~ContentOpfParser() override; bool setup(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index f4700558..b1fbb2fe 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,8 +1,9 @@ #include "TocNcxParser.h" -#include #include +#include "../BookMetadataCache.h" + bool TocNcxParser::setup() { parser = XML_ParserCreate(nullptr); if (!parser) { @@ -167,8 +168,9 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { href = href.substr(0, pos); } - // Push to vector - self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); + if (self->cache) { + self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth); + } // Clear them so we don't re-add them if there are weird XML structures self->currentLabel.clear(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 2f3601a1..e2c86205 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -1,11 +1,10 @@ #pragma once #include +#include #include -#include -#include "Epub/EpubTocEntry.h" -#include "expat.h" +class BookMetadataCache; class TocNcxParser final : public Print { enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT }; @@ -14,6 +13,7 @@ class TocNcxParser final : public Print { size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; std::string currentLabel; std::string currentSrc; @@ -24,10 +24,8 @@ class TocNcxParser final : public Print { static void endElement(void* userData, const XML_Char* name); public: - std::vector toc; - - explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache) + : baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~TocNcxParser() override; bool setup(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7843f5bb..6195ec22 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -212,7 +212,7 @@ void EpubReaderActivity::renderScreen() { } if (!section) { - const auto filepath = epub->getSpineItem(currentSpineIndex); + const auto filepath = epub->getSpineItem(currentSpineIndex).href; Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1cda06ea..3754fa04 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -29,7 +29,7 @@ void EpubReaderChapterSelectionActivity::onEnter() { // Trigger first update updateRequired = true; xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle diff --git a/src/main.cpp b/src/main.cpp index 83a33cc7..b71ea399 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -186,7 +186,8 @@ void setup() { SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); // SD Card Initialization - if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { + // We need 6 open files concurrently when parsing a new chapter + if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ, "/sd", 6)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); setupDisplayAndFonts(); exitActivity(); From 504c7b307d8cb0ec7d54cdca93c720f04464d323 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 21:49:47 +1000 Subject: [PATCH 33/33] Cut release 0.9.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 42de32c0..9cd5df2e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.8.1 +crosspoint_version = 0.9.0 default_envs = default [base]