diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index b41dd3c4..15e50d08 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -7,7 +7,9 @@ namespace { constexpr uint8_t PAGE_FILE_VERSION = 3; } -void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } +void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { + block->render(renderer, fontId, xPos + xOffset, yPos + yOffset); +} void PageLine::serialize(File& file) { serialization::writePod(file, xPos); @@ -27,9 +29,9 @@ std::unique_ptr PageLine::deserialize(File& file) { return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } -void Page::render(GfxRenderer& renderer, const int fontId) const { +void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { for (auto& element : elements) { - element->render(renderer, fontId); + element->render(renderer, fontId, xOffset, yOffset); } } diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 10266534..f43e4987 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -17,7 +17,7 @@ class PageElement { int16_t yPos; 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 render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; virtual void serialize(File& file) = 0; }; @@ -28,7 +28,7 @@ class PageLine final : public PageElement { public: 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 render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; void serialize(File& file) override; static std::unique_ptr deserialize(File& file); }; @@ -37,7 +37,7 @@ class Page { public: // the list of block index and line numbers on this page std::vector> elements; - void render(GfxRenderer& renderer, int fontId) const; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; void serialize(File& file) const; static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index d73f80a5..0e850f31 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -18,14 +18,14 @@ 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, +void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int viewportWidth, const std::function)>& processLine, const bool includeLastLine) { if (words.empty()) { return; } - const int pageWidth = renderer.getScreenWidth() - horizontalMargin; + const int pageWidth = viewportWidth; const int spaceWidth = renderer.getSpaceWidth(fontId); const auto wordWidths = calculateWordWidths(renderer, fontId); const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths); @@ -106,21 +106,34 @@ std::vector ParsedText::computeLineBreaks(const int pageWidth, const int ans[i] = j; // j is the index of the last word in this optimal line } } + + // Handle oversized word: if no valid configuration found, force single-word line + // This prevents cascade failure where one oversized word breaks all preceding words + if (dp[i] == MAX_COST) { + ans[i] = i; // Just this word on its own line + // Inherit cost from next word to allow subsequent words to find valid configurations + if (i + 1 < static_cast(totalWordCount)) { + dp[i] = dp[i + 1]; + } else { + dp[i] = 0; + } + } } // Stores the index of the word that starts the next line (last_word_index + 1) std::vector lineBreakIndices; size_t currentWordIndex = 0; - constexpr size_t MAX_LINES = 1000; while (currentWordIndex < totalWordCount) { - if (lineBreakIndices.size() >= MAX_LINES) { - break; + size_t nextBreakIndex = ans[currentWordIndex] + 1; + + // Safety check: prevent infinite loop if nextBreakIndex doesn't advance + if (nextBreakIndex <= currentWordIndex) { + // Force advance by at least one word to avoid infinite loop + nextBreakIndex = currentWordIndex + 1; } - size_t nextBreakIndex = ans[currentWordIndex] + 1; lineBreakIndices.push_back(nextBreakIndex); - currentWordIndex = nextBreakIndex; } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 7fdb1286..2696407f 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -34,7 +34,7 @@ class ParsedText { 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, + void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int viewportWidth, const std::function)>& processLine, bool includeLastLine = true); }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 5323a7a5..7b815792 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -8,8 +8,8 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 5; -} +constexpr uint8_t SECTION_FILE_VERSION = 6; +} // namespace void Section::onPageComplete(std::unique_ptr page) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; @@ -26,9 +26,8 @@ void Section::onPageComplete(std::unique_ptr page) { pageCount++; } -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 { +void Section::writeCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing, + const int viewportWidth, const int viewportHeight) const { File outputFile; if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { return; @@ -36,18 +35,15 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression, serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, lineCompression); - serialization::writePod(outputFile, marginTop); - serialization::writePod(outputFile, marginRight); - serialization::writePod(outputFile, marginBottom); - serialization::writePod(outputFile, marginLeft); serialization::writePod(outputFile, extraParagraphSpacing); + serialization::writePod(outputFile, viewportWidth); + serialization::writePod(outputFile, viewportHeight); serialization::writePod(outputFile, pageCount); outputFile.close(); } -bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, - const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) { +bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing, + const int viewportWidth, const int viewportHeight) { const auto sectionFilePath = cachePath + "/section.bin"; File inputFile; if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) { @@ -65,20 +61,18 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c return false; } - int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft; + int fileFontId, fileViewportWidth, fileViewportHeight; float fileLineCompression; bool fileExtraParagraphSpacing; serialization::readPod(inputFile, fileFontId); serialization::readPod(inputFile, fileLineCompression); - serialization::readPod(inputFile, fileMarginTop); - serialization::readPod(inputFile, fileMarginRight); - serialization::readPod(inputFile, fileMarginBottom); - serialization::readPod(inputFile, fileMarginLeft); serialization::readPod(inputFile, fileExtraParagraphSpacing); + serialization::readPod(inputFile, fileViewportWidth); + serialization::readPod(inputFile, fileViewportHeight); - if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop || - marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft || - extraParagraphSpacing != fileExtraParagraphSpacing) { + if (fontId != fileFontId || lineCompression != fileLineCompression || + extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth || + viewportHeight != fileViewportHeight) { inputFile.close(); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); clearCache(); @@ -113,28 +107,58 @@ bool Section::clearCache() const { return true; } -bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, - const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) { +bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing, + const int viewportWidth, const int viewportHeight, + const std::function& progressSetupFn, + const std::function& progressFn) { + constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB 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)) { - return false; + + // Retry logic for SD card timing issues + bool success = false; + size_t fileSize = 0; + for (int attempt = 0; attempt < 3 && !success; attempt++) { + if (attempt > 0) { + Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1); + delay(50); // Brief delay before retry + } + + // Remove any incomplete file from previous attempt before retrying + if (SD.exists(tmpHtmlPath.c_str())) { + SD.remove(tmpHtmlPath.c_str()); + } + + File tmpHtml; + if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + continue; + } + success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); + fileSize = tmpHtml.size(); + tmpHtml.close(); + + // If streaming failed, remove the incomplete file immediately + if (!success && SD.exists(tmpHtmlPath.c_str())) { + SD.remove(tmpHtmlPath.c_str()); + Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis()); + } } - 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()); + Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis()); return false; } - Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); + Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); - ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, - marginLeft, extraParagraphSpacing, - [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); + // Only show progress bar for larger chapters where rendering overhead is worth it + if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) { + progressSetupFn(); + } + + ChapterHtmlSlimParser visitor( + tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, + [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }, progressFn); success = visitor.parseAndBuildPages(); SD.remove(tmpHtmlPath.c_str()); @@ -143,7 +167,7 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, return false; } - writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing); + writeCacheMetadata(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight); return true; } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index d7a2c721..a1a62163 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include "Epub.h" @@ -12,8 +13,8 @@ class Section { GfxRenderer& renderer; std::string cachePath; - void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing) const; + void writeCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, + int viewportHeight) const; void onPageComplete(std::unique_ptr page); public: @@ -26,11 +27,12 @@ class Section { 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); + bool loadCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, + int viewportHeight); void setupCacheDir() const; bool clearCache() const; - bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing); + bool persistPageDataToSD(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, + int viewportHeight, const std::function& progressSetupFn = nullptr, + const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSD() const; }; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index bb8b14e8..ef6fdb5d 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -4,11 +4,18 @@ #include void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { + // Validate iterator bounds before rendering + if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { + Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), + (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size()); + return; + } + auto wordIt = words.begin(); auto wordStylesIt = wordStyles.begin(); auto wordXposIt = wordXpos.begin(); - for (int i = 0; i < words.size(); i++) { + for (size_t i = 0; i < words.size(); i++) { renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); std::advance(wordIt, 1); @@ -46,6 +53,13 @@ std::unique_ptr TextBlock::deserialize(File& file) { // words serialization::readPod(file, wc); + + // Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block) + if (wc > 10000) { + Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc); + return nullptr; + } + words.resize(wc); for (auto& w : words) serialization::readString(file, w); @@ -59,6 +73,13 @@ std::unique_ptr TextBlock::deserialize(File& file) { wordStyles.resize(sc); for (auto& s : wordStyles) serialization::readPod(file, s); + // Validate data consistency: all three lists must have the same size + if (wc != xc || wc != sc) { + Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc, + xc, sc); + return nullptr; + } + // style serialization::readPod(file, style); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 766e5ca6..b2dc2c01 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -11,6 +11,9 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); +// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it +constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB + const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); @@ -152,7 +155,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char 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->renderer, self->fontId, self->viewportWidth, [self](const std::shared_ptr& textBlock) { self->addLineToPage(textBlock); }, false); } } @@ -221,6 +224,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + // Get file size for progress calculation + const size_t totalSize = file.size(); + size_t bytesRead = 0; + int lastProgress = -1; + XML_SetUserData(parser, this); XML_SetElementHandler(parser, startElement, endElement); XML_SetCharacterDataHandler(parser, characterData); @@ -249,6 +257,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + // Update progress (call every 10% change to avoid too frequent updates) + // Only show progress for larger chapters where rendering overhead is worth it + bytesRead += len; + if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) { + const int progress = static_cast((bytesRead * 100) / totalSize); + if (lastProgress / 10 != progress / 10) { + lastProgress = progress; + progressFn(progress); + } + } + done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { @@ -282,15 +301,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; - const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom; - if (currentPageNextY + lineHeight > pageHeight) { + if (currentPageNextY + lineHeight > viewportHeight) { completePageFn(std::move(currentPage)); currentPage.reset(new Page()); - currentPageNextY = marginTop; + currentPageNextY = 0; } - currentPage->elements.push_back(std::make_shared(line, marginLeft, currentPageNextY)); + currentPage->elements.push_back(std::make_shared(line, 0, currentPageNextY)); currentPageNextY += lineHeight; } @@ -302,12 +320,12 @@ void ChapterHtmlSlimParser::makePages() { if (!currentPage) { currentPage.reset(new Page()); - currentPageNextY = marginTop; + currentPageNextY = 0; } const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; currentTextBlock->layoutAndExtractLines( - renderer, fontId, marginLeft + marginRight, + renderer, fontId, viewportWidth, [this](const std::shared_ptr& textBlock) { addLineToPage(textBlock); }); // Extra paragraph spacing if enabled if (extraParagraphSpacing) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 7f74602a..53bbbb4f 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -18,6 +18,7 @@ class ChapterHtmlSlimParser { const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; + std::function progressFn; // Progress callback (0-100) int depth = 0; int skipUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX; @@ -31,11 +32,9 @@ class ChapterHtmlSlimParser { int16_t currentPageNextY = 0; int fontId; float lineCompression; - int marginTop; - int marginRight; - int marginBottom; - int marginLeft; bool extraParagraphSpacing; + int viewportWidth; + int viewportHeight; void startNewTextBlock(TextBlock::BLOCK_STYLE style); void makePages(); @@ -46,19 +45,19 @@ class ChapterHtmlSlimParser { public: 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) + const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth, + const int viewportHeight, + const std::function)>& completePageFn, + const std::function& progressFn = nullptr) : filepath(filepath), renderer(renderer), fontId(fontId), lineCompression(lineCompression), - marginTop(marginTop), - marginRight(marginRight), - marginBottom(marginBottom), - marginLeft(marginLeft), extraParagraphSpacing(extraParagraphSpacing), - completePageFn(completePageFn) {} + viewportWidth(viewportWidth), + viewportHeight(viewportHeight), + completePageFn(completePageFn), + progressFn(progressFn) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); void addLineToPage(std::shared_ptr line); diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index c9ad6f85..a034c757 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -3,6 +3,126 @@ #include #include +// ============================================================================ +// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations +// ============================================================================ +// Note: For cover images, dithering is done in JpegToBmpConverter.cpp +// This file handles BMP reading - use simple quantization to avoid double-dithering +constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering +// Brightness adjustments: +constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true +constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true +// ============================================================================ + +// Integer approximation of gamma correction (brightens midtones) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + const int product = gray * 255; + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; +} + +// Simple quantization without dithering - just divide into 4 levels +static inline uint8_t quantizeSimple(int gray) { + if (USE_BRIGHTNESS) { + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + gray = applyGamma(gray); + } + return static_cast(gray >> 6); +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +static inline uint8_t quantizeNoise(int gray, int x, int y) { + if (USE_BRIGHTNESS) { + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + gray = applyGamma(gray); + } + + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + + const int scaled = gray * 3; + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function +static inline uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} + +// Floyd-Steinberg quantization with error diffusion and serpentine scanning +// Returns 2-bit value (0-3) and updates error buffers +static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow, + bool reverseDir) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!reverseDir) { + // Left to right + errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16 + errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16 + errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 + errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16 + } else { + // Right to left (mirrored) + errorCurRow[x] += (error * 7) >> 4; // Left: 7/16 + errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16 + errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 + errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16 + } + + return quantized; +} + +Bitmap::~Bitmap() { + delete[] errorCurRow; + delete[] errorNextRow; +} + uint16_t Bitmap::readLE16(File& f) { const int c0 = f.read(); const int c1 = f.read(); @@ -46,6 +166,8 @@ const char* Bitmap::errorToString(BmpReaderError err) { return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; case BmpReaderError::BadDimensions: return "BadDimensions"; + case BmpReaderError::ImageTooLarge: + return "ImageTooLarge (max 2048x3072)"; case BmpReaderError::PaletteTooLarge: return "PaletteTooLarge"; @@ -99,6 +221,13 @@ BmpReaderError Bitmap::parseHeaders() { if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; + // Safety limits to prevent memory issues on ESP32 + constexpr int MAX_IMAGE_WIDTH = 2048; + constexpr int MAX_IMAGE_HEIGHT = 3072; + if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) { + return BmpReaderError::ImageTooLarge; + } + // Pre-calculate Row Bytes to avoid doing this every row rowBytes = (width * bpp + 31) / 32 * 4; @@ -115,21 +244,56 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::SeekPixelDataFailed; } + // Allocate Floyd-Steinberg error buffers if enabled + if (USE_FLOYD_STEINBERG) { + delete[] errorCurRow; + delete[] errorNextRow; + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + lastRowY = -1; + } + return BmpReaderError::Ok; } // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white -BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { +BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + // Handle Floyd-Steinberg error buffer progression + const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; + if (useFS) { + // Check if we need to advance to next row (or reset if jumping) + if (rowY != lastRowY + 1 && rowY != 0) { + // Non-sequential row access - reset error buffers + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } else if (rowY > 0) { + // Sequential access - swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } + lastRowY = rowY; + } + uint8_t* outPtr = data; uint8_t currentOutByte = 0; int bitShift = 6; + int currentX = 0; // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { - uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 + uint8_t color; + if (useFS) { + // Floyd-Steinberg error diffusion + color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); + } else { + // Simple quantization or noise dithering + color = quantize(lum, currentX, rowY); + } currentOutByte |= (color << bitShift); if (bitShift == 0) { *outPtr++ = currentOutByte; @@ -138,6 +302,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { } else { bitShift -= 2; } + currentX++; }; uint8_t lum; @@ -196,5 +361,12 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } + // Reset Floyd-Steinberg error buffers when rewinding + if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + lastRowY = -1; + } + return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 88dc88de..744cb617 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -15,6 +15,7 @@ enum class BmpReaderError : uint8_t { UnsupportedCompression, BadDimensions, + ImageTooLarge, PaletteTooLarge, SeekPixelDataFailed, @@ -28,8 +29,9 @@ class Bitmap { static const char* errorToString(BmpReaderError err); explicit Bitmap(File& file) : file(file) {} + ~Bitmap(); BmpReaderError parseHeaders(); - BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const; + BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const; BmpReaderError rewindToData() const; int getWidth() const { return width; } int getHeight() const { return height; } @@ -49,4 +51,9 @@ class Bitmap { uint16_t bpp = 0; int rowBytes = 0; uint8_t paletteLum[256] = {}; + + // Floyd-Steinberg dithering state (mutable for const methods) + mutable int16_t* errorCurRow = nullptr; + mutable int16_t* errorNextRow = nullptr; + mutable int lastRowY = -1; // Track row progression for error propagation }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 6433748e..0fc4abf1 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -4,6 +4,37 @@ void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } +void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const { + switch (orientation) { + case Portrait: { + // Logical portrait (480x800) → panel (800x480) + // Rotation: 90 degrees clockwise + *rotatedX = y; + *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + break; + } + case LandscapeClockwise: { + // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) + *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; + *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; + break; + } + case PortraitInverted: { + // Logical portrait (480x800) → panel (800x480) + // Rotation: 90 degrees counter-clockwise + *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y; + *rotatedY = x; + break; + } + case LandscapeCounterClockwise: { + // Logical landscape (800x480) aligned with panel orientation + *rotatedX = x; + *rotatedY = y; + break; + } + } +} + void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); @@ -13,15 +44,14 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { return; } - // Rotate coordinates: portrait (480x800) -> landscape (800x480) - // Rotation: 90 degrees clockwise - const int rotatedX = y; - const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + int rotatedX = 0; + int rotatedY = 0; + rotateCoordinates(x, y, &rotatedX, &rotatedY); - // Bounds checking (portrait: 480x800) + // Bounds checking against physical panel dimensions if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { - Serial.printf("[%lu] [GFX] !! Outside range (%d, %d)\n", millis(), x, y); + Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); return; } @@ -55,7 +85,7 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, const EpdFontStyle style) const { - const int yPos = y + getLineHeight(fontId); + const int yPos = y + getFontAscenderSize(fontId); int xpos = x; // cannot draw a NULL / empty string @@ -115,8 +145,11 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { - // Flip X and Y for portrait mode - einkDisplay.drawImage(bitmap, y, x, height, width); + // TODO: Rotate bits + int rotatedX = 0; + int rotatedY = 0; + rotateCoordinates(x, y, &rotatedX, &rotatedY); + einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, @@ -132,7 +165,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con isScaled = true; } - const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4; + // Calculate output row size (2 bits per pixel, packed into bytes) + // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide + const int outputRowSize = (bitmap.getWidth() + 3) / 4; auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); @@ -154,7 +189,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con break; } - if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) { + if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); free(outputRow); free(rowBytes); @@ -203,23 +238,34 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons einkDisplay.displayBuffer(refreshMode); } -void GfxRenderer::displayWindow(const int x, const int y, const int width, const int height) const { - // Rotate coordinates from portrait (480x800) to landscape (800x480) - // Rotation: 90 degrees clockwise - // Portrait coordinates: (x, y) with dimensions (width, height) - // Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight) - - const int rotatedX = y; - const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1; - const int rotatedWidth = height; - const int rotatedHeight = width; - - einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight); +// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation +int GfxRenderer::getScreenWidth() const { + switch (orientation) { + case Portrait: + case PortraitInverted: + // 480px wide in portrait logical coordinates + return EInkDisplay::DISPLAY_HEIGHT; + case LandscapeClockwise: + case LandscapeCounterClockwise: + // 800px wide in landscape logical coordinates + return EInkDisplay::DISPLAY_WIDTH; + } + return EInkDisplay::DISPLAY_HEIGHT; } -// Note: Internal driver treats screen in command orientation, this library treats in portrait orientation -int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; } -int GfxRenderer::getScreenHeight() { return EInkDisplay::DISPLAY_WIDTH; } +int GfxRenderer::getScreenHeight() const { + switch (orientation) { + case Portrait: + case PortraitInverted: + // 800px tall in portrait logical coordinates + return EInkDisplay::DISPLAY_WIDTH; + case LandscapeClockwise: + case LandscapeCounterClockwise: + // 480px tall in landscape logical coordinates + return EInkDisplay::DISPLAY_HEIGHT; + } + return EInkDisplay::DISPLAY_WIDTH; +} int GfxRenderer::getSpaceWidth(const int fontId) const { if (fontMap.count(fontId) == 0) { @@ -230,6 +276,15 @@ int GfxRenderer::getSpaceWidth(const int fontId) const { return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX; } +int GfxRenderer::getFontAscenderSize(const int fontId) const { + if (fontMap.count(fontId) == 0) { + Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + return 0; + } + + return fontMap.at(fontId).getData(REGULAR)->ascender; +} + int GfxRenderer::getLineHeight(const int fontId) const { if (fontMap.count(fontId) == 0) { Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); @@ -245,7 +300,7 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char constexpr int buttonWidth = 106; constexpr int buttonHeight = 40; constexpr int buttonY = 40; // Distance from bottom - constexpr int textYOffset = 5; // Distance from top of button to text baseline + constexpr int textYOffset = 7; // Distance from top of button to text baseline constexpr int buttonPositions[] = {25, 130, 245, 350}; const char* labels[] = {btn1, btn2, btn3, btn4}; @@ -286,12 +341,13 @@ void GfxRenderer::freeBwBufferChunks() { * This should be called before grayscale buffers are populated. * A `restoreBwBuffer` call should always follow the grayscale render if this method was called. * Uses chunked allocation to avoid needing 48KB of contiguous memory. + * Returns true if buffer was stored successfully, false if allocation failed. */ -void GfxRenderer::storeBwBuffer() { +bool GfxRenderer::storeBwBuffer() { const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); - return; + return false; } // Allocate and copy each chunk @@ -312,7 +368,7 @@ void GfxRenderer::storeBwBuffer() { BW_BUFFER_CHUNK_SIZE); // Free previously allocated chunks freeBwBufferChunks(); - return; + return false; } memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); @@ -320,6 +376,7 @@ void GfxRenderer::storeBwBuffer() { Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE); + return true; } /** @@ -367,6 +424,17 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } +/** + * Cleanup grayscale buffers using the current frame buffer. + * Use this when BW buffer was re-rendered instead of stored/restored. + */ +void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { + uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (frameBuffer) { + einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + } +} + void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, const bool pixelState, const EpdFontStyle style) const { const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); @@ -430,3 +498,32 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, *x += glyph->advanceX; } + +void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const { + switch (orientation) { + case Portrait: + *outTop = VIEWABLE_MARGIN_TOP; + *outRight = VIEWABLE_MARGIN_RIGHT; + *outBottom = VIEWABLE_MARGIN_BOTTOM; + *outLeft = VIEWABLE_MARGIN_LEFT; + break; + case LandscapeClockwise: + *outTop = VIEWABLE_MARGIN_LEFT; + *outRight = VIEWABLE_MARGIN_TOP; + *outBottom = VIEWABLE_MARGIN_RIGHT; + *outLeft = VIEWABLE_MARGIN_BOTTOM; + break; + case PortraitInverted: + *outTop = VIEWABLE_MARGIN_BOTTOM; + *outRight = VIEWABLE_MARGIN_LEFT; + *outBottom = VIEWABLE_MARGIN_TOP; + *outLeft = VIEWABLE_MARGIN_RIGHT; + break; + case LandscapeCounterClockwise: + *outTop = VIEWABLE_MARGIN_RIGHT; + *outRight = VIEWABLE_MARGIN_BOTTOM; + *outBottom = VIEWABLE_MARGIN_LEFT; + *outLeft = VIEWABLE_MARGIN_TOP; + break; + } +} diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 00a525dd..241c76e3 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -12,6 +12,14 @@ class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; + // Logical screen orientation from the perspective of callers + enum Orientation { + Portrait, // 480x800 logical coordinates (current default) + LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom) + PortraitInverted, // 480x800 logical coordinates, inverted + LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation + }; + private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; @@ -20,24 +28,35 @@ class GfxRenderer { EInkDisplay& einkDisplay; RenderMode renderMode; + Orientation orientation; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; std::map fontMap; void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, EpdFontStyle style) const; void freeBwBufferChunks(); + void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; public: - explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW) {} + explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} ~GfxRenderer() = default; + static constexpr int VIEWABLE_MARGIN_TOP = 9; + static constexpr int VIEWABLE_MARGIN_RIGHT = 3; + static constexpr int VIEWABLE_MARGIN_BOTTOM = 3; + static constexpr int VIEWABLE_MARGIN_LEFT = 3; + // Setup void insertFont(int fontId, EpdFontFamily font); + // Orientation control (affects logical width/height and coordinate transforms) + void setOrientation(const Orientation o) { orientation = o; } + Orientation getOrientation() const { return orientation; } + // Screen ops - static int getScreenWidth(); - static int getScreenHeight(); + int getScreenWidth() const; + int getScreenHeight() const; void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; - // EXPERIMENTAL: Windowed update - display only a rectangular region (portrait coordinates) + // EXPERIMENTAL: Windowed update - display only a rectangular region void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; @@ -55,6 +74,7 @@ class GfxRenderer { void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; int getSpaceWidth(int fontId) const; + int getFontAscenderSize(int fontId) const; int getLineHeight(int fontId) const; // UI Components @@ -65,11 +85,13 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - void storeBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully void restoreBwBuffer(); + void cleanupGrayscaleWithFrameBuffer() const; // Low level functions uint8_t* getFrameBuffer() const; static size_t getBufferSize(); void grayscaleRevert() const; + void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; }; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index c2c049a7..0a19701c 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -13,24 +13,296 @@ struct JpegReadContext { 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; +// ============================================================================ +// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations +// ============================================================================ +constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels) +// Dithering method selection (only one should be true, or all false for simple quantization): +constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) +constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) +// Brightness/Contrast adjustments: +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) +constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering +constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) +constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) +// ============================================================================ + +// Integer approximation of gamma correction (brightens midtones) +// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + // Fast integer square root approximation for gamma ~0.5 (brightening) + // This brightens dark/mid tones while preserving highlights + const int product = gray * 255; + // Newton-Raphson integer sqrt (2 iterations for good accuracy) + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; } +// Apply contrast adjustment around midpoint (128) +// factor > 1.0 increases contrast, < 1.0 decreases +static inline int applyContrast(int gray) { + // Integer-based contrast: (gray - 128) * factor + 128 + // Using fixed-point: factor 1.15 ≈ 115/100 + constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); + int adjusted = ((gray - 128) * factorNum) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + return adjusted; +} + +// Combined brightness/contrast/gamma adjustment +static inline int adjustPixel(int gray) { + if (!USE_BRIGHTNESS) return gray; + + // Order: contrast first, then brightness, then gamma + gray = applyContrast(gray); + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + if (gray < 0) gray = 0; + gray = applyGamma(gray); + + return gray; +} + +// Simple quantization without dithering - just divide into 4 levels +static inline uint8_t quantizeSimple(int gray) { + gray = adjustPixel(gray); + // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 + return static_cast(gray >> 6); +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +// Uses integer hash to generate pseudo-random threshold per pixel +static inline uint8_t quantizeNoise(int gray, int x, int y) { + gray = adjustPixel(gray); + + // Generate noise threshold using integer hash (no regular pattern to alias) + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + + // Map gray (0-255) to 4 levels with dithering + const int scaled = gray * 3; + + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function - selects between methods based on config +static inline uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} + +// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results +// Error distribution pattern: +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +// Less error buildup = fewer artifacts than Floyd-Steinberg +class AtkinsonDitherer { + public: + AtkinsonDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~AtkinsonDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + uint8_t processPixel(int gray, int x) { + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + +// Floyd-Steinberg error diffusion dithering with serpentine scanning +// Serpentine scanning alternates direction each row to reduce "worm" artifacts +// Error distribution pattern (left-to-right): +// X 7/16 +// 3/16 5/16 1/16 +// Error distribution pattern (right-to-left, mirrored): +// 1/16 5/16 3/16 +// 7/16 X +class FloydSteinbergDitherer { + public: + FloydSteinbergDitherer(int width) : width(width), rowCount(0) { + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + } + + ~FloydSteinbergDitherer() { + delete[] errorCurRow; + delete[] errorNextRow; + } + + // Process a single pixel and return quantized 2-bit value + // x is the logical x position (0 to width-1), direction handled internally + uint8_t processPixel(int gray, int x, bool reverseDirection) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!reverseDirection) { + // Left to right: standard distribution + // Right: 7/16 + errorCurRow[x + 2] += (error * 7) >> 4; + // Bottom-left: 3/16 + errorNextRow[x] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-right: 1/16 + errorNextRow[x + 2] += (error) >> 4; + } else { + // Right to left: mirrored distribution + // Left: 7/16 + errorCurRow[x] += (error * 7) >> 4; + // Bottom-right: 3/16 + errorNextRow[x + 2] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-left: 1/16 + errorNextRow[x] += (error) >> 4; + } + + return quantized; + } + + // Call at the end of each row to swap buffers + void nextRow() { + // Swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + // Clear the next row buffer + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount++; + } + + // Check if current row should be processed in reverse + bool isReverseRow() const { return (rowCount & 1) != 0; } + + // Reset for a new image or MCU block + void reset() { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount = 0; + } + + private: + int width; + int rowCount; + int16_t* errorCurRow; + int16_t* errorNextRow; +}; + 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); @@ -38,13 +310,49 @@ inline void write32(Print& out, const uint32_t value) { } 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 8-bit grayscale (256 levels) +void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded + const int imageSize = bytesPerRow * height; + const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA) + const uint32_t fileSize = 14 + 40 + paletteSize + imageSize; + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); + write32(bmpOut, 0); // Reserved + write32(bmpOut, 14 + 40 + paletteSize); // 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, 8); // Bits per pixel (8 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, 256); // colorsUsed + write32(bmpOut, 256); // colorsImportant + + // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes) + for (int i = 0; i < 256; i++) { + bmpOut.write(static_cast(i)); // Blue + bmpOut.write(static_cast(i)); // Green + bmpOut.write(static_cast(i)); // Red + bmpOut.write(static_cast(0)); // Reserved + } +} + // 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) @@ -135,13 +443,59 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { 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); + // Safety limits to prevent memory issues on ESP32 + constexpr int MAX_IMAGE_WIDTH = 2048; + constexpr int MAX_IMAGE_HEIGHT = 3072; + constexpr int MAX_MCU_ROW_BYTES = 65536; - // Calculate row parameters - const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4; + if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { + Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width, + imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); + return false; + } - // Allocate row buffer for packed 2-bit pixels + // Calculate output dimensions (pre-scale to fit display exactly) + int outWidth = imageInfo.m_width; + int outHeight = imageInfo.m_height; + // Use fixed-point scaling (16.16) for sub-pixel accuracy + uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point + uint32_t scaleY_fp = 65536; + bool needsScaling = false; + + if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + // Calculate scale to fit within target dimensions while maintaining aspect ratio + const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + + outWidth = static_cast(imageInfo.m_width * scale); + outHeight = static_cast(imageInfo.m_height * scale); + + // Ensure at least 1 pixel + if (outWidth < 1) outWidth = 1; + if (outHeight < 1) outHeight = 1; + + // Calculate fixed-point scale factors (source pixels per output pixel) + // scaleX_fp = (srcWidth << 16) / outWidth + scaleX_fp = (static_cast(imageInfo.m_width) << 16) / outWidth; + scaleY_fp = (static_cast(imageInfo.m_height) << 16) / outHeight; + needsScaling = true; + + Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, + imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + } + + // Write BMP header with output dimensions + int bytesPerRow; + if (USE_8BIT_OUTPUT) { + writeBmpHeader8bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 3) / 4 * 4; + } else { + writeBmpHeader(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth * 2 + 31) / 32 * 4; + } + + // Allocate row buffer auto* rowBuffer = static_cast(malloc(bytesPerRow)); if (!rowBuffer) { Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); @@ -152,13 +506,48 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { // 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()); + + // Validate MCU row buffer size before allocation + if (mcuRowPixels > MAX_MCU_ROW_BYTES) { + Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels, + MAX_MCU_ROW_BYTES); free(rowBuffer); return false; } + auto* mcuRowBuffer = static_cast(malloc(mcuRowPixels)); + if (!mcuRowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels); + free(rowBuffer); + return false; + } + + // Create ditherer if enabled (only for 2-bit output) + // Use OUTPUT dimensions for dithering (after prescaling) + AtkinsonDitherer* atkinsonDitherer = nullptr; + FloydSteinbergDitherer* fsDitherer = nullptr; + if (!USE_8BIT_OUTPUT) { + if (USE_ATKINSON) { + atkinsonDitherer = new AtkinsonDitherer(outWidth); + } else if (USE_FLOYD_STEINBERG) { + fsDitherer = new FloydSteinbergDitherer(outWidth); + } + } + + // For scaling: accumulate source rows into scaled output rows + // We need to track which source Y maps to which output Y + // Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format) + uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums) + uint16_t* rowCount = nullptr; // Count of source pixels accumulated per output X + int currentOutY = 0; // Current output row being accumulated + uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point) + + if (needsScaling) { + rowAccum = new uint32_t[outWidth](); + rowCount = new uint16_t[outWidth](); + nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1) + } + // Process MCUs row-by-row and write to BMP as we go (top-down) const int mcuPixelWidth = imageInfo.m_MCUWidth; @@ -181,75 +570,164 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { return false; } - // 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) + // picojpeg stores MCU data in 8x8 blocks + // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128 for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { const int pixelX = mcuX * mcuPixelWidth + blockX; + if (pixelX >= imageInfo.m_width) continue; - // Skip pixels outside image width (can happen with MCU alignment) - if (pixelX >= imageInfo.m_width) { - continue; - } + // Calculate proper block offset for picojpeg buffer + const int blockCol = blockX / 8; + const int blockRow = blockY / 8; + const int localX = blockX % 8; + const int localY = blockY % 8; + const int blocksPerRow = mcuPixelWidth / 8; + const int blockIndex = blockRow * blocksPerRow + blockCol; + const int pixelOffset = blockIndex * 64 + localY * 8 + localX; - // 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[mcuIndex]; + gray = imageInfo.m_pMCUBufR[pixelOffset]; } else { - // RGB image - convert to grayscale - 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; + const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset]; + const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset]; + const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset]; + gray = (r * 25 + g * 50 + b * 25) / 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 + // Process source rows from this MCU row 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); + const int bufferY = y - startRow; - // 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); + if (!needsScaling) { + // No scaling - direct output (1:1 mapping) + memset(rowBuffer, 0, bytesPerRow); - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0 - rowBuffer[byteIndex] |= (twoBit << bitOffset); + if (USE_8BIT_OUTPUT) { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + rowBuffer[x] = adjustPixel(gray); + } + } else { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + uint8_t twoBit; + if (atkinsonDitherer) { + twoBit = atkinsonDitherer->processPixel(gray, x); + } else if (fsDitherer) { + twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + } else { + twoBit = quantize(gray, x, y); + } + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + } + bmpOut.write(rowBuffer, bytesPerRow); + } else { + // Fixed-point area averaging for exact fit scaling + // For each output pixel X, accumulate source pixels that map to it + // srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16) + const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width; + + for (int outX = 0; outX < outWidth; outX++) { + // Calculate source X range for this output pixel + const int srcXStart = (static_cast(outX) * scaleX_fp) >> 16; + const int srcXEnd = (static_cast(outX + 1) * scaleX_fp) >> 16; + + // Accumulate all source pixels in this range + int sum = 0; + int count = 0; + for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) { + sum += srcRow[srcX]; + count++; + } + + // Handle edge case: if no pixels in range, use nearest + if (count == 0 && srcXStart < imageInfo.m_width) { + sum = srcRow[srcXStart]; + count = 1; + } + + rowAccum[outX] += sum; + rowCount[outX] += count; + } + + // Check if we've crossed into the next output row + // Current source Y in fixed point: y << 16 + const uint32_t srcY_fp = static_cast(y + 1) << 16; + + // Output row when source Y crosses the boundary + if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { + memset(rowBuffer, 0, bytesPerRow); + + if (USE_8BIT_OUTPUT) { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + rowBuffer[x] = adjustPixel(gray); + } + } else { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + uint8_t twoBit; + if (atkinsonDitherer) { + twoBit = atkinsonDitherer->processPixel(gray, x); + } else if (fsDitherer) { + twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + } else { + twoBit = quantize(gray, x, currentOutY); + } + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + } + + bmpOut.write(rowBuffer, bytesPerRow); + currentOutY++; + + // Reset accumulators for next output row + memset(rowAccum, 0, outWidth * sizeof(uint32_t)); + memset(rowCount, 0, outWidth * sizeof(uint16_t)); + + // Update boundary for next output row + nextOutY_srcStart = static_cast(currentOutY + 1) * scaleY_fp; + } } - - // Write row with padding - bmpOut.write(rowBuffer, bytesPerRow); } } // Clean up + if (rowAccum) { + delete[] rowAccum; + } + if (rowCount) { + delete[] rowCount; + } + if (atkinsonDitherer) { + delete atkinsonDitherer; + } + if (fsDitherer) { + delete fsDitherer; + } free(mcuRowBuffer); free(rowBuffer); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index fc881e25..1cb76e59 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -6,7 +6,7 @@ class ZipFile; class JpegToBmpConverter { static void writeBmpHeader(Print& bmpOut, int width, int height); - static uint8_t grayscaleTo2Bit(uint8_t grayscale); + // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); diff --git a/lib/Xtc/README b/lib/Xtc/README new file mode 100644 index 00000000..1f55effa --- /dev/null +++ b/lib/Xtc/README @@ -0,0 +1,40 @@ +# XTC/XTCH Library + +XTC ebook format support for CrossPoint Reader. + +## Supported Formats + +| Format | Extension | Description | +|--------|-----------|----------------------------------------------| +| XTC | `.xtc` | Container with XTG pages (1-bit monochrome) | +| XTCH | `.xtch` | Container with XTH pages (2-bit grayscale) | + +## Format Overview + +XTC/XTCH are container formats designed for ESP32 e-paper displays. They store pre-rendered bitmap pages optimized for the XTeink X4 e-reader (480x800 resolution). + +### Container Structure (XTC/XTCH) + +- 56-byte header with metadata offsets +- Optional metadata (title, author, etc.) +- Page index table (16 bytes per page) +- Page data (XTG or XTH format) + +### Page Formats + +#### XTG (1-bit monochrome) + +- Row-major storage, 8 pixels per byte +- MSB first (bit 7 = leftmost pixel) +- 0 = Black, 1 = White + +#### XTH (2-bit grayscale) + +- Two bit planes stored sequentially +- Column-major order (right to left) +- 8 vertical pixels per byte +- Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black + +## Reference + +Original format info: diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp new file mode 100644 index 00000000..fe0b107e --- /dev/null +++ b/lib/Xtc/Xtc.cpp @@ -0,0 +1,337 @@ +/** + * Xtc.cpp + * + * Main XTC ebook class implementation + * XTC ebook support for CrossPoint Reader + */ + +#include "Xtc.h" + +#include +#include +#include + +bool Xtc::load() { + Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str()); + + // Initialize parser + parser.reset(new xtc::XtcParser()); + + // Open XTC file + xtc::XtcError err = parser->open(filepath.c_str()); + if (err != xtc::XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err)); + parser.reset(); + return false; + } + + loaded = true; + Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount()); + return true; +} + +bool Xtc::clearCache() const { + if (!SD.exists(cachePath.c_str())) { + Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis()); + return true; + } + + if (!FsHelpers::removeDir(cachePath.c_str())) { + Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis()); + return false; + } + + Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis()); + return true; +} + +void Xtc::setupCacheDir() const { + if (SD.exists(cachePath.c_str())) { + return; + } + + // Create directories recursively + for (size_t i = 1; i < cachePath.length(); i++) { + if (cachePath[i] == '/') { + SD.mkdir(cachePath.substr(0, i).c_str()); + } + } + SD.mkdir(cachePath.c_str()); +} + +std::string Xtc::getTitle() const { + if (!loaded || !parser) { + return ""; + } + + // Try to get title from XTC metadata first + std::string title = parser->getTitle(); + if (!title.empty()) { + return title; + } + + // Fallback: extract filename from path as title + size_t lastSlash = filepath.find_last_of('/'); + size_t lastDot = filepath.find_last_of('.'); + + if (lastSlash == std::string::npos) { + lastSlash = 0; + } else { + lastSlash++; + } + + if (lastDot == std::string::npos || lastDot <= lastSlash) { + return filepath.substr(lastSlash); + } + + return filepath.substr(lastSlash, lastDot - lastSlash); +} + +std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Xtc::generateCoverBmp() const { + // Already generated + if (SD.exists(getCoverBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis()); + return false; + } + + if (parser->getPageCount() == 0) { + Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get first page info for cover + xtc::PageInfo pageInfo; + if (!parser->getPageInfo(0, pageInfo)) { + Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); + return false; + } + + // Get bit depth + const uint8_t bitDepth = parser->getBitDepth(); + + // Allocate buffer for page data + // XTG (1-bit): Row-major, ((width+7)/8) * height bytes + // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes + size_t bitmapSize; + if (bitDepth == 2) { + bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + } + uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); + if (!pageBuffer) { + Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); + return false; + } + + // Load first page (cover) + size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); + if (bytesRead == 0) { + Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis()); + free(pageBuffer); + return false; + } + + // Create BMP file + File coverBmp; + if (!FsHelpers::openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { + Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write BMP header + // BMP file header (14 bytes) + const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes + const uint32_t imageSize = rowSize * pageInfo.height; + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data + + // File header + coverBmp.write('B'); + coverBmp.write('M'); + coverBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + coverBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) + coverBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header (BITMAPINFOHEADER - 40 bytes) + uint32_t dibHeaderSize = 40; + coverBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t width = pageInfo.width; + coverBmp.write(reinterpret_cast(&width), 4); + int32_t height = -static_cast(pageInfo.height); // Negative for top-down + coverBmp.write(reinterpret_cast(&height), 4); + uint16_t planes = 1; + coverBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 1; // 1-bit monochrome + coverBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; // BI_RGB (no compression) + coverBmp.write(reinterpret_cast(&compression), 4); + coverBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; // 72 DPI + coverBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + coverBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 2; + coverBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 2; + coverBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette (2 colors for 1-bit) + // XTC uses inverted polarity: 0 = black, 1 = white + // Color 0: Black (text/foreground in XTC) + uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; + coverBmp.write(black, 4); + // Color 1: White (background in XTC) + uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; + coverBmp.write(white, 4); + + // Write bitmap data + // BMP requires 4-byte row alignment + const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size + + if (bitDepth == 2) { + // XTH 2-bit mode: Two bit planes, column-major order + // - Columns scanned right to left (x = width-1 down to 0) + // - 8 vertical pixels per byte (MSB = topmost pixel in group) + // - First plane: Bit1, Second plane: Bit2 + // - Pixel value = (bit1 << 1) | bit2 + const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; + const uint8_t* plane1 = pageBuffer; // Bit1 plane + const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane + const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column + + // Allocate a row buffer for 1-bit output + uint8_t* rowBuffer = static_cast(malloc(dstRowSize)); + if (!rowBuffer) { + free(pageBuffer); + coverBmp.close(); + return false; + } + + for (uint16_t y = 0; y < pageInfo.height; y++) { + memset(rowBuffer, 0xFF, dstRowSize); // Start with all white + + for (uint16_t x = 0; x < pageInfo.width; x++) { + // Column-major, right to left: column index = (width - 1 - x) + const size_t colIndex = pageInfo.width - 1 - x; + const size_t byteInCol = y / 8; + const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel + + const size_t byteOffset = colIndex * colBytes + byteInCol; + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + const uint8_t pixelValue = (bit1 << 1) | bit2; + + // Threshold: 0=white (1); 1,2,3=black (0) + if (pixelValue >= 1) { + // Set bit to 0 (black) in BMP format + const size_t dstByte = x / 8; + const size_t dstBit = 7 - (x % 8); + rowBuffer[dstByte] &= ~(1 << dstBit); + } + } + + // Write converted row + coverBmp.write(rowBuffer, dstRowSize); + + // Pad to 4-byte boundary + uint8_t padding[4] = {0, 0, 0, 0}; + size_t paddingSize = rowSize - dstRowSize; + if (paddingSize > 0) { + coverBmp.write(padding, paddingSize); + } + } + + free(rowBuffer); + } else { + // 1-bit source: write directly with proper padding + const size_t srcRowSize = (pageInfo.width + 7) / 8; + + for (uint16_t y = 0; y < pageInfo.height; y++) { + // Write source row + coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize); + + // Pad to 4-byte boundary + uint8_t padding[4] = {0, 0, 0, 0}; + size_t paddingSize = rowSize - srcRowSize; + if (paddingSize > 0) { + coverBmp.write(padding, paddingSize); + } + } + } + + coverBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str()); + return true; +} + +uint32_t Xtc::getPageCount() const { + if (!loaded || !parser) { + return 0; + } + return parser->getPageCount(); +} + +uint16_t Xtc::getPageWidth() const { + if (!loaded || !parser) { + return 0; + } + return parser->getWidth(); +} + +uint16_t Xtc::getPageHeight() const { + if (!loaded || !parser) { + return 0; + } + return parser->getHeight(); +} + +uint8_t Xtc::getBitDepth() const { + if (!loaded || !parser) { + return 1; // Default to 1-bit + } + return parser->getBitDepth(); +} + +size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { + if (!loaded || !parser) { + return 0; + } + return const_cast(parser.get())->loadPage(pageIndex, buffer, bufferSize); +} + +xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex, + std::function callback, + size_t chunkSize) const { + if (!loaded || !parser) { + return xtc::XtcError::FILE_NOT_FOUND; + } + return const_cast(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize); +} + +uint8_t Xtc::calculateProgress(uint32_t currentPage) const { + if (!loaded || !parser || parser->getPageCount() == 0) { + return 0; + } + return static_cast((currentPage + 1) * 100 / parser->getPageCount()); +} + +xtc::XtcError Xtc::getLastError() const { + if (!parser) { + return xtc::XtcError::FILE_NOT_FOUND; + } + return parser->getLastError(); +} diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h new file mode 100644 index 00000000..42e05ef3 --- /dev/null +++ b/lib/Xtc/Xtc.h @@ -0,0 +1,97 @@ +/** + * Xtc.h + * + * Main XTC ebook class for CrossPoint Reader + * Provides EPUB-like interface for XTC file handling + */ + +#pragma once + +#include +#include + +#include "Xtc/XtcParser.h" +#include "Xtc/XtcTypes.h" + +/** + * XTC Ebook Handler + * + * Handles XTC file loading, page access, and cover image generation. + * Interface is designed to be similar to Epub class for easy integration. + */ +class Xtc { + std::string filepath; + std::string cachePath; + std::unique_ptr parser; + bool loaded; + + public: + explicit Xtc(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)), loaded(false) { + // Create cache key based on filepath (same as Epub) + cachePath = cacheDir + "/xtc_" + std::to_string(std::hash{}(this->filepath)); + } + ~Xtc() = default; + + /** + * Load XTC file + * @return true on success + */ + bool load(); + + /** + * Clear cached data + * @return true on success + */ + bool clearCache() const; + + /** + * Setup cache directory + */ + void setupCacheDir() const; + + // Path accessors + const std::string& getCachePath() const { return cachePath; } + const std::string& getPath() const { return filepath; } + + // Metadata + std::string getTitle() const; + + // Cover image support (for sleep screen) + std::string getCoverBmpPath() const; + bool generateCoverBmp() const; + + // Page access + uint32_t getPageCount() const; + uint16_t getPageWidth() const; + uint16_t getPageHeight() const; + uint8_t getBitDepth() const; // 1 = XTC (1-bit), 2 = XTCH (2-bit) + + /** + * Load page bitmap data + * @param pageIndex Page index (0-based) + * @param buffer Output buffer + * @param bufferSize Buffer size + * @return Number of bytes read + */ + size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; + + /** + * Load page with streaming callback + * @param pageIndex Page index + * @param callback Callback for each chunk + * @param chunkSize Chunk size + * @return Error code + */ + xtc::XtcError loadPageStreaming(uint32_t pageIndex, + std::function callback, + size_t chunkSize = 1024) const; + + // Progress calculation + uint8_t calculateProgress(uint32_t currentPage) const; + + // Check if file is loaded + bool isLoaded() const { return loaded; } + + // Error information + xtc::XtcError getLastError() const; +}; diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp new file mode 100644 index 00000000..a443f57b --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -0,0 +1,316 @@ +/** + * XtcParser.cpp + * + * XTC file parsing implementation + * XTC ebook support for CrossPoint Reader + */ + +#include "XtcParser.h" + +#include +#include + +#include + +namespace xtc { + +XtcParser::XtcParser() + : m_isOpen(false), + m_defaultWidth(DISPLAY_WIDTH), + m_defaultHeight(DISPLAY_HEIGHT), + m_bitDepth(1), + m_lastError(XtcError::OK) { + memset(&m_header, 0, sizeof(m_header)); +} + +XtcParser::~XtcParser() { close(); } + +XtcError XtcParser::open(const char* filepath) { + // Close if already open + if (m_isOpen) { + close(); + } + + // Open file + if (!FsHelpers::openFileForRead("XTC", filepath, m_file)) { + m_lastError = XtcError::FILE_NOT_FOUND; + return m_lastError; + } + + // Read header + m_lastError = readHeader(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + + // Read title if available + readTitle(); + + // Read page table + m_lastError = readPageTable(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + + m_isOpen = true; + Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount, + m_defaultWidth, m_defaultHeight); + return XtcError::OK; +} + +void XtcParser::close() { + if (m_isOpen) { + m_file.close(); + m_isOpen = false; + } + m_pageTable.clear(); + m_title.clear(); + memset(&m_header, 0, sizeof(m_header)); +} + +XtcError XtcParser::readHeader() { + // Read first 56 bytes of header + size_t bytesRead = m_file.read(reinterpret_cast(&m_header), sizeof(XtcHeader)); + if (bytesRead != sizeof(XtcHeader)) { + return XtcError::READ_ERROR; + } + + // Verify magic number (accept both XTC and XTCH) + if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) { + Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic, + XTC_MAGIC, XTCH_MAGIC); + return XtcError::INVALID_MAGIC; + } + + // Determine bit depth from file magic + m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1; + + // Check version + if (m_header.version > 1) { + Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version); + return XtcError::INVALID_VERSION; + } + + // Basic validation + if (m_header.pageCount == 0) { + return XtcError::CORRUPTED_HEADER; + } + + Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic, + (m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, m_header.pageCount, m_bitDepth); + + return XtcError::OK; +} + +XtcError XtcParser::readTitle() { + // Title is usually at offset 0x38 (56) for 88-byte headers + // Read title as null-terminated UTF-8 string + if (m_header.titleOffset == 0) { + m_header.titleOffset = 0x38; // Default offset + } + + if (!m_file.seek(m_header.titleOffset)) { + return XtcError::READ_ERROR; + } + + char titleBuf[128] = {0}; + m_file.read(reinterpret_cast(titleBuf), sizeof(titleBuf) - 1); + m_title = titleBuf; + + Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str()); + return XtcError::OK; +} + +XtcError XtcParser::readPageTable() { + if (m_header.pageTableOffset == 0) { + Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis()); + return XtcError::CORRUPTED_HEADER; + } + + // Seek to page table + if (!m_file.seek(m_header.pageTableOffset)) { + Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset); + return XtcError::READ_ERROR; + } + + m_pageTable.resize(m_header.pageCount); + + // Read page table entries + for (uint16_t i = 0; i < m_header.pageCount; i++) { + PageTableEntry entry; + size_t bytesRead = m_file.read(reinterpret_cast(&entry), sizeof(PageTableEntry)); + if (bytesRead != sizeof(PageTableEntry)) { + Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i); + return XtcError::READ_ERROR; + } + + m_pageTable[i].offset = static_cast(entry.dataOffset); + m_pageTable[i].size = entry.dataSize; + m_pageTable[i].width = entry.width; + m_pageTable[i].height = entry.height; + m_pageTable[i].bitDepth = m_bitDepth; + + // Update default dimensions from first page + if (i == 0) { + m_defaultWidth = entry.width; + m_defaultHeight = entry.height; + } + } + + Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount); + return XtcError::OK; +} + +bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const { + if (pageIndex >= m_pageTable.size()) { + return false; + } + info = m_pageTable[pageIndex]; + return true; +} + +size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { + if (!m_isOpen) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (pageIndex >= m_header.pageCount) { + m_lastError = XtcError::PAGE_OUT_OF_RANGE; + return 0; + } + + const PageInfo& page = m_pageTable[pageIndex]; + + // Seek to page data + if (!m_file.seek(page.offset)) { + Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + // Read page header (XTG for 1-bit, XTH for 2-bit - same structure) + XtgPageHeader pageHeader; + size_t headerRead = m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)); + if (headerRead != sizeof(XtgPageHeader)) { + Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + // Verify page magic (XTG for 1-bit, XTH for 2-bit) + const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC; + if (pageHeader.magic != expectedMagic) { + Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex, + pageHeader.magic, expectedMagic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + // Calculate bitmap size based on bit depth + // XTG (1-bit): Row-major, ((width+7)/8) * height bytes + // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes + size_t bitmapSize; + if (m_bitDepth == 2) { + // XTH: two bit planes, each containing (width * height) bits rounded up to bytes + bitmapSize = ((static_cast(pageHeader.width) * pageHeader.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; + } + + // Check buffer size + if (bufferSize < bitmapSize) { + Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize); + m_lastError = XtcError::MEMORY_ERROR; + return 0; + } + + // Read bitmap data + size_t bytesRead = m_file.read(buffer, bitmapSize); + if (bytesRead != bitmapSize) { + Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + m_lastError = XtcError::OK; + return bytesRead; +} + +XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, + std::function callback, + size_t chunkSize) { + if (!m_isOpen) { + return XtcError::FILE_NOT_FOUND; + } + + if (pageIndex >= m_header.pageCount) { + return XtcError::PAGE_OUT_OF_RANGE; + } + + const PageInfo& page = m_pageTable[pageIndex]; + + // Seek to page data + if (!m_file.seek(page.offset)) { + return XtcError::READ_ERROR; + } + + // Read and skip page header (XTG for 1-bit, XTH for 2-bit) + XtgPageHeader pageHeader; + size_t headerRead = m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)); + const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC; + if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) { + return XtcError::READ_ERROR; + } + + // Calculate bitmap size based on bit depth + // XTG (1-bit): Row-major, ((width+7)/8) * height bytes + // XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes + size_t bitmapSize; + if (m_bitDepth == 2) { + bitmapSize = ((static_cast(pageHeader.width) * pageHeader.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; + } + + // Read in chunks + std::vector chunk(chunkSize); + size_t totalRead = 0; + + while (totalRead < bitmapSize) { + size_t toRead = std::min(chunkSize, bitmapSize - totalRead); + size_t bytesRead = m_file.read(chunk.data(), toRead); + + if (bytesRead == 0) { + return XtcError::READ_ERROR; + } + + callback(chunk.data(), bytesRead, totalRead); + totalRead += bytesRead; + } + + return XtcError::OK; +} + +bool XtcParser::isValidXtcFile(const char* filepath) { + File file = SD.open(filepath, FILE_READ); + if (!file) { + return false; + } + + uint32_t magic = 0; + size_t bytesRead = file.read(reinterpret_cast(&magic), sizeof(magic)); + file.close(); + + if (bytesRead != sizeof(magic)) { + return false; + } + + return (magic == XTC_MAGIC || magic == XTCH_MAGIC); +} + +} // namespace xtc diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h new file mode 100644 index 00000000..b0a402aa --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.h @@ -0,0 +1,96 @@ +/** + * XtcParser.h + * + * XTC file parsing and page data extraction + * XTC ebook support for CrossPoint Reader + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "XtcTypes.h" + +namespace xtc { + +/** + * XTC File Parser + * + * Reads XTC files from SD card and extracts page data. + * Designed for ESP32-C3's limited RAM (~380KB) using streaming. + */ +class XtcParser { + public: + XtcParser(); + ~XtcParser(); + + // File open/close + XtcError open(const char* filepath); + void close(); + bool isOpen() const { return m_isOpen; } + + // Header information access + const XtcHeader& getHeader() const { return m_header; } + uint16_t getPageCount() const { return m_header.pageCount; } + uint16_t getWidth() const { return m_defaultWidth; } + uint16_t getHeight() const { return m_defaultHeight; } + uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH + + // Page information + bool getPageInfo(uint32_t pageIndex, PageInfo& info) const; + + /** + * Load page bitmap (raw 1-bit data, skipping XTG header) + * + * @param pageIndex Page index (0-based) + * @param buffer Output buffer (caller allocated) + * @param bufferSize Buffer size + * @return Number of bytes read on success, 0 on failure + */ + size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + + /** + * Streaming page load + * Memory-efficient method that reads page data in chunks. + * + * @param pageIndex Page index + * @param callback Callback function to receive data chunks + * @param chunkSize Chunk size (default: 1024 bytes) + * @return Error code + */ + XtcError loadPageStreaming(uint32_t pageIndex, + std::function callback, + size_t chunkSize = 1024); + + // Get title from metadata + std::string getTitle() const { return m_title; } + + // Validation + static bool isValidXtcFile(const char* filepath); + + // Error information + XtcError getLastError() const { return m_lastError; } + + private: + File m_file; + bool m_isOpen; + XtcHeader m_header; + std::vector m_pageTable; + std::string m_title; + uint16_t m_defaultWidth; + uint16_t m_defaultHeight; + uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit) + XtcError m_lastError; + + // Internal helper functions + XtcError readHeader(); + XtcError readPageTable(); + XtcError readTitle(); +}; + +} // namespace xtc diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h new file mode 100644 index 00000000..30761d97 --- /dev/null +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -0,0 +1,147 @@ +/** + * XtcTypes.h + * + * XTC file format type definitions + * XTC ebook support for CrossPoint Reader + * + * XTC is the native binary ebook format for XTeink X4 e-reader. + * It stores pre-rendered bitmap images per page. + * + * Format based on EPUB2XTC converter by Rafal-P-Mazur + */ + +#pragma once + +#include + +namespace xtc { + +// XTC file magic numbers (little-endian) +// "XTC\0" = 0x58, 0x54, 0x43, 0x00 +constexpr uint32_t XTC_MAGIC = 0x00435458; // "XTC\0" in little-endian (1-bit fast mode) +// "XTCH" = 0x58, 0x54, 0x43, 0x48 +constexpr uint32_t XTCH_MAGIC = 0x48435458; // "XTCH" in little-endian (2-bit high quality mode) +// "XTG\0" = 0x58, 0x54, 0x47, 0x00 +constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for 1-bit page data +// "XTH\0" = 0x58, 0x54, 0x48, 0x00 +constexpr uint32_t XTH_MAGIC = 0x00485458; // "XTH\0" for 2-bit page data + +// XTeink X4 display resolution +constexpr uint16_t DISPLAY_WIDTH = 480; +constexpr uint16_t DISPLAY_HEIGHT = 800; + +// XTC file header (56 bytes) +#pragma pack(push, 1) +struct XtcHeader { + uint32_t magic; // 0x00: Magic number "XTC\0" (0x00435458) + uint16_t version; // 0x04: Format version (typically 1) + uint16_t pageCount; // 0x06: Total page count + uint32_t flags; // 0x08: Flags/reserved + uint32_t headerSize; // 0x0C: Size of header section (typically 88) + uint32_t reserved1; // 0x10: Reserved + uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8! + uint64_t pageTableOffset; // 0x18: Page table offset + uint64_t dataOffset; // 0x20: First page data offset + uint64_t reserved2; // 0x28: Reserved + uint32_t titleOffset; // 0x30: Title string offset + uint32_t padding; // 0x34: Padding to 56 bytes +}; +#pragma pack(pop) + +// Page table entry (16 bytes per page) +#pragma pack(push, 1) +struct PageTableEntry { + uint64_t dataOffset; // 0x00: Absolute offset to page data + uint32_t dataSize; // 0x08: Page data size in bytes + uint16_t width; // 0x0C: Page width (480) + uint16_t height; // 0x0E: Page height (800) +}; +#pragma pack(pop) + +// XTG/XTH page data header (22 bytes) +// Used for both 1-bit (XTG) and 2-bit (XTH) formats +#pragma pack(push, 1) +struct XtgPageHeader { + uint32_t magic; // 0x00: File identifier (XTG: 0x00475458, XTH: 0x00485458) + uint16_t width; // 0x04: Image width (pixels) + uint16_t height; // 0x06: Image height (pixels) + uint8_t colorMode; // 0x08: Color mode (0=monochrome) + uint8_t compression; // 0x09: Compression (0=uncompressed) + uint32_t dataSize; // 0x0A: Image data size (bytes) + uint64_t md5; // 0x0E: MD5 checksum (first 8 bytes, optional) + // Followed by bitmap data at offset 0x16 (22) + // + // XTG (1-bit): Row-major, 8 pixels/byte, MSB first + // dataSize = ((width + 7) / 8) * height + // + // XTH (2-bit): Two bit planes, column-major (right-to-left), 8 vertical pixels/byte + // dataSize = ((width * height + 7) / 8) * 2 + // First plane: Bit1 for all pixels + // Second plane: Bit2 for all pixels + // pixelValue = (bit1 << 1) | bit2 +}; +#pragma pack(pop) + +// Page information (internal use, optimized for memory) +struct PageInfo { + uint32_t offset; // File offset to page data (max 4GB file size) + uint32_t size; // Data size (bytes) + uint16_t width; // Page width + uint16_t height; // Page height + uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale) + uint8_t padding; // Alignment padding +}; // 16 bytes total + +// Error codes +enum class XtcError { + OK = 0, + FILE_NOT_FOUND, + INVALID_MAGIC, + INVALID_VERSION, + CORRUPTED_HEADER, + PAGE_OUT_OF_RANGE, + READ_ERROR, + WRITE_ERROR, + MEMORY_ERROR, + DECOMPRESSION_ERROR, +}; + +// Convert error code to string +inline const char* errorToString(XtcError err) { + switch (err) { + case XtcError::OK: + return "OK"; + case XtcError::FILE_NOT_FOUND: + return "File not found"; + case XtcError::INVALID_MAGIC: + return "Invalid magic number"; + case XtcError::INVALID_VERSION: + return "Unsupported version"; + case XtcError::CORRUPTED_HEADER: + return "Corrupted header"; + case XtcError::PAGE_OUT_OF_RANGE: + return "Page out of range"; + case XtcError::READ_ERROR: + return "Read error"; + case XtcError::WRITE_ERROR: + return "Write error"; + case XtcError::MEMORY_ERROR: + return "Memory allocation error"; + case XtcError::DECOMPRESSION_ERROR: + return "Decompression error"; + default: + return "Unknown error"; + } +} + +/** + * Check if filename has XTC/XTCH extension + */ +inline bool isXtcExtension(const char* filename) { + if (!filename) return false; + const char* ext = strrchr(filename, '.'); + if (!ext) return false; + return (strcasecmp(ext, ".xtc") == 0 || strcasecmp(ext, ".xtch") == 0); +} + +} // namespace xtc diff --git a/platformio.ini b/platformio.ini index 5abe0289..4bac6129 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.9.0 +crosspoint_version = 0.10.0 default_envs = default [base] diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 467ee9ca..93284222 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -10,7 +10,8 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; -constexpr uint8_t SETTINGS_COUNT = 3; +// Increment this when adding new persisted settings fields +constexpr uint8_t SETTINGS_COUNT = 5; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -28,6 +29,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); + serialization::writePod(outputFile, statusBar); + serialization::writePod(outputFile, orientation); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -51,7 +54,7 @@ bool CrossPointSettings::loadFromFile() { uint8_t fileSettingsCount = 0; serialization::readPod(inputFile, fileSettingsCount); - // load settings that exist + // load settings that exist (support older files with fewer fields) uint8_t settingsRead = 0; do { serialization::readPod(inputFile, sleepScreen); @@ -60,6 +63,10 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, shortPwrBtn); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, statusBar); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, orientation); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 14c33322..2b99664e 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -18,12 +18,27 @@ class CrossPointSettings { // Should match with SettingsActivity text enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + // Status bar display type enum + enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; + + enum ORIENTATION { + PORTRAIT = 0, // 480x800 logical coordinates (current default) + LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) + INVERTED = 2, // 480x800 logical coordinates, inverted + LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation + }; + // Sleep screen settings uint8_t sleepScreen = DARK; + // Status bar settings + uint8_t statusBar = FULL; // Text rendering settings uint8_t extraParagraphSpacing = 1; // Duration of the power button press uint8_t shortPwrBtn = 0; + // EPUB reading orientation settings + // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise + uint8_t orientation = PORTRAIT; ~CrossPointSettings() = default; diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index 78a12482..a1530882 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -8,11 +8,11 @@ void BootActivity::onEnter() { Activity::onEnter(); - 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.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, "BOOTING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4bc70f57..fdf84b66 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -12,6 +13,20 @@ #include "config.h" #include "images/CrossLarge.h" +namespace { +// Check if path has XTC extension (.xtc or .xtch) +bool isXtcFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + if (ext4 == ".xtc") return true; + if (path.length() >= 5) { + std::string ext5 = path.substr(path.length() - 5); + if (ext5 == ".xtch") return true; + } + return false; +} +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); @@ -112,7 +127,7 @@ void SleepActivity::renderDefaultSleepScreen() const { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); + 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"); @@ -176,19 +191,41 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); - if (!lastEpub.load()) { - Serial.println("[SLP] Failed to load last epub"); - return renderDefaultSleepScreen(); - } + std::string coverBmpPath; - if (!lastEpub.generateCoverBmp()) { - Serial.println("[SLP] Failed to generate cover bmp"); - return renderDefaultSleepScreen(); + // Check if the current book is XTC or EPUB + if (isXtcFile(APP_STATE.openEpubPath)) { + // Handle XTC file + Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastXtc.load()) { + Serial.println("[SLP] Failed to load last XTC"); + return renderDefaultSleepScreen(); + } + + if (!lastXtc.generateCoverBmp()) { + Serial.println("[SLP] Failed to generate XTC cover bmp"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastXtc.getCoverBmpPath(); + } else { + // Handle EPUB file + 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(); + } + + coverBmpPath = lastEpub.getCoverBmpPath(); } File file; - if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) { + if (FsHelpers::openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 38dc8542..5e330d8e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -56,7 +56,7 @@ void HomeActivity::loop() { const int menuCount = getMenuItemCount(); - if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (hasContinueReading) { // Menu: Continue Reading, Browse, File transfer, Settings if (selectorIndex == 0) { @@ -106,7 +106,7 @@ void HomeActivity::render() const { renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); // Draw selection - renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); + renderer.fillRect(0, 60 + selectorIndex * 30 - 2, pageWidth - 1, 30); int menuY = 60; int menuIndex = 0; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 68c6481e..5bf25f13 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -187,12 +187,25 @@ void WifiSelectionActivity::selectNetwork(const int index) { if (selectedRequiresPassword) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; - enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", - "", // No initial text - 64, // Max password length - false // Show password by default (hard keyboard to use) - )); + // Don't allow screen updates while changing activity + xSemaphoreTake(renderingMutex, portMAX_DELAY); + enterNewActivity(new KeyboardEntryActivity( + renderer, inputManager, "Enter WiFi Password", + "", // No initial text + 50, // Y position + 64, // Max password length + false, // Show password by default (hard keyboard to use) + [this](const std::string& text) { + enteredPassword = text; + exitActivity(); + }, + [this] { + state = WifiSelectionState::NETWORK_LIST; + updateRequired = true; + exitActivity(); + })); updateRequired = true; + xSemaphoreGive(renderingMutex); } else { // Connect directly for open networks attemptConnection(); @@ -208,11 +221,6 @@ void WifiSelectionActivity::attemptConnection() { WiFi.mode(WIFI_STA); - // Get password from keyboard if we just entered it - if (subActivity && !usedSavedPassword) { - enteredPassword = static_cast(subActivity.get())->getText(); - } - if (selectedRequiresPassword && !enteredPassword.empty()) { WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); } else { @@ -269,6 +277,11 @@ void WifiSelectionActivity::checkConnectionStatus() { } void WifiSelectionActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + // Check scan progress if (state == WifiSelectionState::SCANNING) { processWifiScanResults(); @@ -281,24 +294,9 @@ void WifiSelectionActivity::loop() { return; } - // Handle password entry state - if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) { - const auto keyboard = static_cast(subActivity.get()); - keyboard->handleInput(); - - if (keyboard->isComplete()) { - attemptConnection(); - return; - } - - if (keyboard->isCancelled()) { - state = WifiSelectionState::NETWORK_LIST; - exitActivity(); - updateRequired = true; - return; - } - - updateRequired = true; + if (state == WifiSelectionState::PASSWORD_ENTRY) { + // Reach here once password entry finished in subactivity + attemptConnection(); return; } @@ -441,6 +439,10 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi void WifiSelectionActivity::displayTaskLoop() { while (true) { + if (subActivity) { + continue; + } + if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -461,9 +463,6 @@ void WifiSelectionActivity::render() const { case WifiSelectionState::NETWORK_LIST: renderNetworkList(); break; - case WifiSelectionState::PASSWORD_ENTRY: - renderPasswordEntry(); - break; case WifiSelectionState::CONNECTING: renderConnecting(); break; @@ -561,23 +560,6 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); } -void WifiSelectionActivity::renderPasswordEntry() const { - // 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.replace(27, networkInfo.length() - 27, "..."); - } - renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); - - // Draw keyboard - if (subActivity) { - static_cast(subActivity.get())->render(58); - } -} - void WifiSelectionActivity::renderConnecting() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f4905d60..3e194149 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -16,10 +16,8 @@ constexpr int pagesPerRefresh = 15; constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr float lineCompression = 0.95f; -constexpr int marginTop = 8; -constexpr int marginRight = 10; -constexpr int marginBottom = 22; -constexpr int marginLeft = 10; +constexpr int horizontalPadding = 5; +constexpr int statusBarMargin = 19; } // namespace void EpubReaderActivity::taskTrampoline(void* param) { @@ -34,6 +32,24 @@ void EpubReaderActivity::onEnter() { return; } + // Configure screen orientation based on settings + switch (SETTINGS.orientation) { + case CrossPointSettings::ORIENTATION::PORTRAIT: + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); + break; + case CrossPointSettings::ORIENTATION::INVERTED: + renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); + break; + default: + break; + } + renderingMutex = xSemaphoreCreateMutex(); epub->setupCacheDir(); @@ -67,6 +83,9 @@ void EpubReaderActivity::onEnter() { void EpubReaderActivity::onExit() { ActivityWithSubactivity::onExit(); + // Reset orientation back to portrait for the rest of the UI + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -87,7 +106,7 @@ void EpubReaderActivity::loop() { } // Enter chapter selection activity - if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); @@ -219,31 +238,70 @@ void EpubReaderActivity::renderScreen() { return; } + // Apply screen viewable areas and additional padding + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginLeft += horizontalPadding; + orientedMarginRight += horizontalPadding; + orientedMarginBottom += statusBarMargin; + if (!section) { 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, - SETTINGS.extraParagraphSpacing)) { + + const auto viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const auto viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + + if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth, + viewportHeight)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); + // Progress bar dimensions + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); + const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxWidthNoBar = textWidth + boxMargin * 2; + const int boxHeightWithBar = renderer.getLineHeight(READER_FONT_ID) + barHeight + boxMargin * 3; + const int boxHeightNoBar = renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2; + const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2; + const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2; + constexpr int boxY = 50; + const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2; + + // Always show "Indexing..." text first { - const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); - constexpr int margin = 20; - const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 50; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; - renderer.fillRect(x, y, w, h, false); - renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing..."); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); + renderer.drawText(READER_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); renderer.displayBuffer(); pagesUntilFullRefresh = 0; } section->setupCacheDir(); - if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, - marginLeft, SETTINGS.extraParagraphSpacing)) { + + // Setup callback - only called for chapters >= 50KB, redraws with progress bar + auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] { + renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); + renderer.drawText(READER_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + }; + + // Progress callback to update progress bar + auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { + const int fillWidth = (barWidth - 2) * progress / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + }; + + if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth, + viewportHeight, progressSetup, progressCallback)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; @@ -264,7 +322,7 @@ void EpubReaderActivity::renderScreen() { if (section->pageCount == 0) { Serial.printf("[%lu] [ERS] No pages to render\n", millis()); renderer.drawCenteredText(READER_FONT_ID, 300, "Empty chapter", true, BOLD); - renderStatusBar(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); return; } @@ -272,7 +330,7 @@ void EpubReaderActivity::renderScreen() { if (section->currentPage < 0 || section->currentPage >= section->pageCount) { Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); renderer.drawCenteredText(READER_FONT_ID, 300, "Out of bounds", true, BOLD); - renderStatusBar(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); return; } @@ -286,7 +344,7 @@ void EpubReaderActivity::renderScreen() { return renderScreen(); } const auto start = millis(); - renderContents(std::move(p)); + renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } @@ -302,9 +360,11 @@ void EpubReaderActivity::renderScreen() { } } -void EpubReaderActivity::renderContents(std::unique_ptr page) { - page->render(renderer, READER_FONT_ID); - renderStatusBar(); +void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, + const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) { + page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); pagesUntilFullRefresh = pagesPerRefresh; @@ -321,13 +381,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr page) { { renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->render(renderer, READER_FONT_ID); + page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop); renderer.copyGrayscaleLsbBuffers(); // Render and copy to MSB buffer renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->render(renderer, READER_FONT_ID); + page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop); renderer.copyGrayscaleMsbBuffers(); // display grayscale part @@ -339,72 +399,90 @@ void EpubReaderActivity::renderContents(std::unique_ptr page) { renderer.restoreBwBuffer(); } -void EpubReaderActivity::renderStatusBar() const { - constexpr auto textY = 776; +void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) const { + // determine visible status bar elements + const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; - // Calculate progress in book - const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; - const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + // Position status bar near the bottom of the logical screen, regardless of orientation + const auto screenHeight = renderer.getScreenHeight(); + const auto textY = screenHeight - orientedMarginBottom + 2; + int percentageTextWidth = 0; + int progressTextWidth = 0; - // Right aligned text for progress counter - const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + - " " + std::to_string(bookProgress) + "%"; - const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); - renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, - progress.c_str()); + if (showProgress) { + // Calculate progress in book + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); - // Left aligned battery icon and percentage - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; - const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); - - // 1 column on left, 2 columns on right, 5 columns of battery body - constexpr int batteryWidth = 15; - constexpr int batteryHeight = 10; - constexpr int x = marginLeft; - constexpr int y = 783; - - // Top line - renderer.drawLine(x, y, x + batteryWidth - 4, y); - // Bottom line - renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); - // Left line - renderer.drawLine(x, y, x, y + batteryHeight - 1); - // Battery end - renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); - renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); - renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); - renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); - - // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; - if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow + // Right aligned text for progress counter + const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + + " " + std::to_string(bookProgress) + "%"; + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progress.c_str()); } - renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); - // Centered chatper title text - // Page width minus existing content with 30px padding on each side - const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; - const int titleMarginRight = progressTextWidth + 30 + marginRight; - const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; - const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (showBattery) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, 20 + orientedMarginLeft, textY, percentageText.c_str()); - std::string title; - int titleWidth; - if (tocIndex == -1) { - title = "Unnamed"; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); - } else { - const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); - while (titleWidth > availableTextWidth && title.length() > 11) { - title.replace(title.length() - 8, 8, "..."); - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 10; + const int x = orientedMarginLeft; + const int y = screenHeight - orientedMarginBottom + 5; + + // Top line + renderer.drawLine(x, y, x + batteryWidth - 4, y); + // Bottom line + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y, x, y + batteryHeight - 1); + // Battery end + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; // Ensure we don't overflow } + renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); } - renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + if (showChapterTitle) { + // Centered chatper title text + // Page width minus existing content with 30px padding on each side + const int titleMarginLeft = 20 + percentageTextWidth + 30 + orientedMarginLeft; + const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; + const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; + const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + + std::string title; + int titleWidth; + if (tocIndex == -1) { + title = "Unnamed"; + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); + } else { + const auto tocItem = epub->getTocItem(tocIndex); + title = tocItem.title; + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + while (titleWidth > availableTextWidth && title.length() > 11) { + title.replace(title.length() - 8, 8, "..."); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + } + + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + } } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 143f56b1..f1abc92d 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -22,8 +22,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); - void renderContents(std::unique_ptr p); - void renderStatusBar() const; + void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, + int orientedMarginBottom, int orientedMarginLeft); + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; public: explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr epub, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 3754fa04..ab9d8f76 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -7,10 +7,26 @@ #include "config.h" namespace { -constexpr int PAGE_ITEMS = 24; +// Time threshold for treating a long press as a page-up/page-down constexpr int SKIP_PAGE_MS = 700; } // namespace +int EpubReaderChapterSelectionActivity::getPageItems() const { + // Layout constants used in renderScreen + constexpr int startY = 60; + constexpr int lineHeight = 30; + + const int screenHeight = renderer.getScreenHeight(); + const int availableHeight = screenHeight - startY; + int items = availableHeight / lineHeight; + + // Ensure we always have at least one item per page to avoid division by zero + if (items < 1) { + items = 1; + } + return items; +} + void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -56,22 +72,23 @@ void EpubReaderChapterSelectionActivity::loop() { inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = getPageItems(); - if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { onSelectSpineIndex(selectorIndex); - } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + } else if (inputManager.wasReleased(InputManager::BTN_BACK)) { onGoBack(); } else if (prevReleased) { if (skipPage) { selectorIndex = - ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount(); + ((selectorIndex / pageItems - 1) * pageItems + epub->getSpineItemsCount()) % epub->getSpineItemsCount(); } else { selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount(); } updateRequired = true; } else if (nextReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount(); + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getSpineItemsCount(); } else { selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount(); } @@ -95,17 +112,18 @@ void EpubReaderChapterSelectionActivity::renderScreen() { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD); - 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 < epub->getSpineItemsCount() && i < pageStartIndex + PAGE_ITEMS; i++) { + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); + for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + pageItems; i++) { const int tocIndex = epub->getTocIndexForSpineIndex(i); if (tocIndex == -1) { - renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex); + renderer.drawText(UI_FONT_ID, 20, 60 + (i % pageItems) * 30, "Unnamed", i != selectorIndex); } else { auto item = epub->getTocItem(tocIndex); - renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % PAGE_ITEMS) * 30, item.title.c_str(), + renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % pageItems) * 30, item.title.c_str(), i != selectorIndex); } } diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index 8c1adef8..fefd225c 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -18,6 +18,10 @@ class EpubReaderChapterSelectionActivity final : public Activity { const std::function onGoBack; const std::function onSelectSpineIndex; + // Number of items that fit on a page, derived from logical screen height. + // This adapts automatically when switching between portrait and landscape. + int getPageItems() const; + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 853b06f1..e891d773 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -40,8 +40,12 @@ void FileSelectionActivity::loadFiles() { if (file.isDirectory()) { files.emplace_back(filename + "/"); - } else if (filename.substr(filename.length() - 5) == ".epub") { - files.emplace_back(filename); + } else { + std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : ""; + std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : ""; + if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") { + files.emplace_back(filename); + } } file.close(); } @@ -101,7 +105,7 @@ void FileSelectionActivity::loop() { const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; - if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (files.empty()) { return; } @@ -158,20 +162,20 @@ void FileSelectionActivity::displayTaskLoop() { void FileSelectionActivity::render() const { renderer.clearScreen(); - const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageWidth = renderer.getScreenWidth(); renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text - renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); + renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", ""); if (files.empty()) { - renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); + renderer.drawText(UI_FONT_ID, 20, 60, "No books found"); renderer.displayBuffer(); return; } const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; - renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); + 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()); diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 519a33a2..222cc979 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -5,6 +5,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "Xtc.h" +#include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { @@ -15,6 +17,17 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { return filePath.substr(0, lastSlash); } +bool ReaderActivity::isXtcFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + if (ext4 == ".xtc") return true; + if (path.length() >= 5) { + std::string ext5 = path.substr(path.length() - 5); + if (ext5 == ".xtch") return true; + } + return false; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SD.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -30,54 +43,102 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { return nullptr; } -void ReaderActivity::onSelectEpubFile(const std::string& path) { - currentEpubPath = path; // Track current book path +std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { + if (!SD.exists(path.c_str())) { + Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); + return nullptr; + } + + auto xtc = std::unique_ptr(new Xtc(path, "/.crosspoint")); + if (xtc->load()) { + return xtc; + } + + Serial.printf("[%lu] [ ] Failed to load XTC\n", millis()); + return nullptr; +} + +void ReaderActivity::onSelectBookFile(const std::string& path) { + currentBookPath = path; // Track current book path exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); - auto epub = loadEpub(path); - if (epub) { - onGoToEpubReader(std::move(epub)); + if (isXtcFile(path)) { + // Load XTC file + auto xtc = loadXtc(path); + if (xtc) { + onGoToXtcReader(std::move(xtc)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load XTC", REGULAR, + EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { - exitActivity(); - enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR, - EInkDisplay::HALF_REFRESH)); - delay(2000); - onGoToFileSelection(); + // Load EPUB file + auto epub = loadEpub(path); + if (epub) { + onGoToEpubReader(std::move(epub)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR, + EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } } -void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { +void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) { exitActivity(); // If coming from a book, start in that book's folder; otherwise start from root - const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); + const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + renderer, inputManager, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath)); } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { const auto epubPath = epub->getPath(); - currentEpubPath = epubPath; + currentBookPath = epubPath; exitActivity(); enterNewActivity(new EpubReaderActivity( renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); }, [this] { onGoBack(); })); } +void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { + const auto xtcPath = xtc->getPath(); + currentBookPath = xtcPath; + exitActivity(); + enterNewActivity(new XtcReaderActivity( + renderer, inputManager, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); - if (initialEpubPath.empty()) { + if (initialBookPath.empty()) { onGoToFileSelection(); // Start from root when entering via Browse return; } - currentEpubPath = initialEpubPath; - auto epub = loadEpub(initialEpubPath); - if (!epub) { - onGoBack(); - return; - } + currentBookPath = initialBookPath; - onGoToEpubReader(std::move(epub)); + if (isXtcFile(initialBookPath)) { + auto xtc = loadXtc(initialBookPath); + if (!xtc) { + onGoBack(); + return; + } + onGoToXtcReader(std::move(xtc)); + } else { + auto epub = loadEpub(initialBookPath); + if (!epub) { + onGoBack(); + return; + } + onGoToEpubReader(std::move(epub)); + } } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 5bb34193..f40417e8 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -4,23 +4,27 @@ #include "../ActivityWithSubactivity.h" class Epub; +class Xtc; class ReaderActivity final : public ActivityWithSubactivity { - std::string initialEpubPath; - std::string currentEpubPath; // Track current book path for navigation + std::string initialBookPath; + std::string currentBookPath; // Track current book path for navigation const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); + static std::unique_ptr loadXtc(const std::string& path); + static bool isXtcFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); - void onSelectEpubFile(const std::string& path); - void onGoToFileSelection(const std::string& fromEpubPath = ""); + void onSelectBookFile(const std::string& path); + void onGoToFileSelection(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); + void onGoToXtcReader(std::unique_ptr xtc); public: - explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath, + explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath, const std::function& onGoBack) : ActivityWithSubactivity("Reader", renderer, inputManager), - initialEpubPath(std::move(initialEpubPath)), + initialBookPath(std::move(initialBookPath)), onGoBack(onGoBack) {} void onEnter() override; }; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp new file mode 100644 index 00000000..aa9de70b --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -0,0 +1,360 @@ +/** + * XtcReaderActivity.cpp + * + * XTC ebook reader activity implementation + * Displays pre-rendered XTC pages on e-ink display + */ + +#include "XtcReaderActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "config.h" + +namespace { +constexpr int pagesPerRefresh = 15; +constexpr unsigned long skipPageMs = 700; +constexpr unsigned long goHomeMs = 1000; +} // namespace + +void XtcReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void XtcReaderActivity::onEnter() { + Activity::onEnter(); + + if (!xtc) { + return; + } + + renderingMutex = xSemaphoreCreateMutex(); + + xtc->setupCacheDir(); + + // Load saved progress + loadProgress(); + + // Save current XTC as last opened book + APP_STATE.openEpubPath = xtc->getPath(); + APP_STATE.saveToFile(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&XtcReaderActivity::taskTrampoline, "XtcReaderActivityTask", + 4096, // Stack size (smaller than EPUB since no parsing needed) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void XtcReaderActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + xtc.reset(); +} + +void XtcReaderActivity::loop() { + // Long press BACK (1s+) goes directly to home + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { + onGoBack(); + return; + } + + 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); + + if (!prevReleased && !nextReleased) { + return; + } + + // Handle end of book + if (currentPage >= xtc->getPageCount()) { + currentPage = xtc->getPageCount() - 1; + updateRequired = true; + return; + } + + const bool skipPages = inputManager.getHeldTime() > skipPageMs; + const int skipAmount = skipPages ? 10 : 1; + + if (prevReleased) { + if (currentPage >= static_cast(skipAmount)) { + currentPage -= skipAmount; + } else { + currentPage = 0; + } + updateRequired = true; + } else if (nextReleased) { + currentPage += skipAmount; + if (currentPage >= xtc->getPageCount()) { + currentPage = xtc->getPageCount(); // Allow showing "End of book" + } + updateRequired = true; + } +} + +void XtcReaderActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void XtcReaderActivity::renderScreen() { + if (!xtc) { + return; + } + + // Bounds check + if (currentPage >= xtc->getPageCount()) { + // Show end of book screen + renderer.clearScreen(); + renderer.drawCenteredText(UI_FONT_ID, 300, "End of book", true, BOLD); + renderer.displayBuffer(); + return; + } + + renderPage(); + saveProgress(); +} + +void XtcReaderActivity::renderPage() { + const uint16_t pageWidth = xtc->getPageWidth(); + const uint16_t pageHeight = xtc->getPageHeight(); + const uint8_t bitDepth = xtc->getBitDepth(); + + // Calculate buffer size for one page + // XTG (1-bit): Row-major, ((width+7)/8) * height bytes + // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes + size_t pageBufferSize; + if (bitDepth == 2) { + pageBufferSize = ((static_cast(pageWidth) * pageHeight + 7) / 8) * 2; + } else { + pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; + } + + // Allocate page buffer + uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); + if (!pageBuffer) { + Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize); + renderer.clearScreen(); + renderer.drawCenteredText(UI_FONT_ID, 300, "Memory error", true, BOLD); + renderer.displayBuffer(); + return; + } + + // Load page data + size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); + if (bytesRead == 0) { + Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage); + free(pageBuffer); + renderer.clearScreen(); + renderer.drawCenteredText(UI_FONT_ID, 300, "Page load error", true, BOLD); + renderer.displayBuffer(); + return; + } + + // Clear screen first + renderer.clearScreen(); + + // Copy page bitmap using GfxRenderer's drawPixel + // XTC/XTCH pages are pre-rendered with status bar included, so render full page + const uint16_t maxSrcY = pageHeight; + + if (bitDepth == 2) { + // XTH 2-bit mode: Two bit planes, column-major order + // - Columns scanned right to left (x = width-1 down to 0) + // - 8 vertical pixels per byte (MSB = topmost pixel in group) + // - First plane: Bit1, Second plane: Bit2 + // - Pixel value = (bit1 << 1) | bit2 + // - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black + + const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; + const uint8_t* plane1 = pageBuffer; // Bit1 plane + const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane + const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height) + + // Lambda to get pixel value at (x, y) + auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t { + const size_t colIndex = pageWidth - 1 - x; + const size_t byteInCol = y / 8; + const size_t bitInByte = 7 - (y % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + return (bit1 << 1) | bit2; + }; + + // Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory) + // Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame + + // Count pixel distribution for debugging + uint32_t pixelCounts[4] = {0, 0, 0, 0}; + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + pixelCounts[getPixelValue(x, y)]++; + } + } + Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(), + pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]); + + // Pass 1: BW buffer - draw all non-white pixels as black + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + if (getPixelValue(x, y) >= 1) { + renderer.drawPixel(x, y, true); + } + } + } + + // Display BW with conditional refresh based on pagesUntilFullRefresh + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = pagesPerRefresh; + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } + + // Pass 2: LSB buffer - mark DARK gray only (XTH value 1) + // In LUT: 0 bit = apply gray effect, 1 bit = untouched + renderer.clearScreen(0x00); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + if (getPixelValue(x, y) == 1) { // Dark grey only + renderer.drawPixel(x, y, false); + } + } + } + renderer.copyGrayscaleLsbBuffers(); + + // Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2) + // In LUT: 0 bit = apply gray effect, 1 bit = untouched + renderer.clearScreen(0x00); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + const uint8_t pv = getPixelValue(x, y); + if (pv == 1 || pv == 2) { // Dark grey or Light grey + renderer.drawPixel(x, y, false); + } + } + } + renderer.copyGrayscaleMsbBuffers(); + + // Display grayscale overlay + renderer.displayGrayBuffer(); + + // Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer) + renderer.clearScreen(); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + if (getPixelValue(x, y) >= 1) { + renderer.drawPixel(x, y, true); + } + } + } + + // Cleanup grayscale buffers with current frame buffer + renderer.cleanupGrayscaleWithFrameBuffer(); + + free(pageBuffer); + + Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1, + xtc->getPageCount()); + return; + } else { + // 1-bit mode: 8 pixels per byte, MSB first + const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width + + for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) { + const size_t srcRowStart = srcY * srcRowBytes; + + for (uint16_t srcX = 0; srcX < pageWidth; srcX++) { + // Read source pixel (MSB first, bit 7 = leftmost pixel) + const size_t srcByte = srcRowStart + srcX / 8; + const size_t srcBit = 7 - (srcX % 8); + const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white + + if (isBlack) { + renderer.drawPixel(srcX, srcY, true); + } + } + } + } + // White pixels are already cleared by clearScreen() + + free(pageBuffer); + + // XTC pages already have status bar pre-rendered, no need to add our own + + // Display with appropriate refresh + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = pagesPerRefresh; + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } + + Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(), + bitDepth); +} + +void XtcReaderActivity::saveProgress() const { + File f; + if (FsHelpers::openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = (currentPage >> 16) & 0xFF; + data[3] = (currentPage >> 24) & 0xFF; + f.write(data, 4); + f.close(); + } +} + +void XtcReaderActivity::loadProgress() { + File f; + if (FsHelpers::openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); + Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage); + + // Validate page number + if (currentPage >= xtc->getPageCount()) { + currentPage = 0; + } + } + f.close(); + } +} diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h new file mode 100644 index 00000000..f923d8ad --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.h @@ -0,0 +1,41 @@ +/** + * XtcReaderActivity.h + * + * XTC ebook reader activity for CrossPoint Reader + * Displays pre-rendered XTC pages on e-ink display + */ + +#pragma once + +#include +#include +#include +#include + +#include "activities/Activity.h" + +class XtcReaderActivity final : public Activity { + std::shared_ptr xtc; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + uint32_t currentPage = 0; + int pagesUntilFullRefresh = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void saveProgress() const; + void loadProgress(); + + public: + explicit XtcReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr xtc, + const std::function& onGoBack, const std::function& onGoHome) + : Activity("XtcReader", renderer, inputManager), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f7af052e..71fe331f 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,12 +9,17 @@ // Define the static settings list namespace { -constexpr int settingsCount = 4; +constexpr int settingsCount = 6; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, + {"Reading Orientation", + SettingType::ENUM, + &CrossPointSettings::orientation, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, }; } // namespace @@ -138,8 +143,8 @@ void SettingsActivity::displayTaskLoop() { void SettingsActivity::render() const { renderer.clearScreen(); - 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, "Settings", true, BOLD); diff --git a/src/activities/util/FullScreenMessageActivity.cpp b/src/activities/util/FullScreenMessageActivity.cpp index cf84cc5c..54740b61 100644 --- a/src/activities/util/FullScreenMessageActivity.cpp +++ b/src/activities/util/FullScreenMessageActivity.cpp @@ -8,7 +8,7 @@ void FullScreenMessageActivity::onEnter() { Activity::onEnter(); const auto height = renderer.getLineHeight(UI_FONT_ID); - const auto top = (GfxRenderer::getScreenHeight() - height) / 2; + const auto top = (renderer.getScreenHeight() - height) / 2; renderer.clearScreen(); renderer.drawCenteredText(UI_FONT_ID, top, text.c_str(), true, style); diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index b4ed01ca..8a72f1bc 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -10,41 +10,55 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = { // Keyboard layouts - uppercase/symbols const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", - "ZXCVBNM<>?", "^ _____?", "SPECIAL ROW"}; -void KeyboardEntryActivity::setText(const std::string& newText) { - text = newText; - if (maxLength > 0 && text.length() > maxLength) { - text.resize(maxLength); - } +void KeyboardEntryActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); } -void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) { - if (!newTitle.empty()) { - title = newTitle; +void KeyboardEntryActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); } - text = newInitialText; - selectedRow = 0; - selectedCol = 0; - shiftActive = false; - complete = false; - cancelled = false; } void KeyboardEntryActivity::onEnter() { Activity::onEnter(); - // Reset state when entering the activity - complete = false; - cancelled = false; + renderingMutex = xSemaphoreCreateMutex(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); } -void KeyboardEntryActivity::loop() { - handleInput(); - render(10); +void KeyboardEntryActivity::onExit() { + Activity::onExit(); + + // 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; } -int KeyboardEntryActivity::getRowLength(int row) const { +int KeyboardEntryActivity::getRowLength(const int row) const { if (row < 0 || row >= NUM_ROWS) return 0; // Return actual length of each row based on keyboard layout @@ -58,7 +72,7 @@ int KeyboardEntryActivity::getRowLength(int row) const { case 3: return 10; // zxcvbnm,./ case 4: - return 10; // ^, space (5 wide), backspace, OK (2 wide) + return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK default: return 0; } @@ -75,8 +89,8 @@ char KeyboardEntryActivity::getSelectedChar() const { void KeyboardEntryActivity::handleKeyPress() { // Handle special row (bottom row with shift, space, backspace, done) - if (selectedRow == SHIFT_ROW) { - if (selectedCol == SHIFT_COL) { + if (selectedRow == SPECIAL_ROW) { + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { // Shift toggle shiftActive = !shiftActive; return; @@ -90,7 +104,7 @@ void KeyboardEntryActivity::handleKeyPress() { return; } - if (selectedCol == BACKSPACE_COL) { + if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { // Backspace if (!text.empty()) { text.pop_back(); @@ -100,7 +114,6 @@ void KeyboardEntryActivity::handleKeyPress() { if (selectedCol >= DONE_COL) { // Done button - complete = true; if (onComplete) { onComplete(text); } @@ -109,42 +122,61 @@ void KeyboardEntryActivity::handleKeyPress() { } // Regular character - char c = getSelectedChar(); - if (c != '\0' && c != '^' && c != '_' && c != '<') { - if (maxLength == 0 || text.length() < maxLength) { - text += c; - // Auto-disable shift after typing a letter - if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { - shiftActive = false; - } + const char c = getSelectedChar(); + if (c == '\0') { + return; + } + + if (maxLength == 0 || text.length() < maxLength) { + text += c; + // Auto-disable shift after typing a letter + if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { + shiftActive = false; } } } -bool KeyboardEntryActivity::handleInput() { - if (complete || cancelled) { - return false; - } - - bool handled = false; - +void KeyboardEntryActivity::loop() { // Navigation if (inputManager.wasPressed(InputManager::BTN_UP)) { if (selectedRow > 0) { selectedRow--; // Clamp column to valid range for new row - int maxCol = getRowLength(selectedRow) - 1; + const int maxCol = getRowLength(selectedRow) - 1; if (selectedCol > maxCol) selectedCol = maxCol; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_DOWN)) { + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_DOWN)) { if (selectedRow < NUM_ROWS - 1) { selectedRow++; - int maxCol = getRowLength(selectedRow) - 1; + const int maxCol = getRowLength(selectedRow) - 1; if (selectedCol > maxCol) selectedCol = maxCol; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_LEFT)) { + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_LEFT)) { + // Special bottom row case + if (selectedRow == SPECIAL_ROW) { + // Bottom row has special key widths + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { + // In shift key, do nothing + } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { + // In space bar, move to shift + selectedCol = SHIFT_COL; + } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { + // In backspace, move to space + selectedCol = SPACE_COL; + } else if (selectedCol >= DONE_COL) { + // At done button, move to backspace + selectedCol = BACKSPACE_COL; + } + updateRequired = true; + return; + } + if (selectedCol > 0) { selectedCol--; } else if (selectedRow > 0) { @@ -152,9 +184,31 @@ bool KeyboardEntryActivity::handleInput() { selectedRow--; selectedCol = getRowLength(selectedRow) - 1; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { - int maxCol = getRowLength(selectedRow) - 1; + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { + const int maxCol = getRowLength(selectedRow) - 1; + + // Special bottom row case + if (selectedRow == SPECIAL_ROW) { + // Bottom row has special key widths + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { + // In shift key, move to space + selectedCol = SPACE_COL; + } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { + // In space bar, move to backspace + selectedCol = BACKSPACE_COL; + } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { + // In backspace, move to done + selectedCol = DONE_COL; + } else if (selectedCol >= DONE_COL) { + // At done button, do nothing + } + updateRequired = true; + return; + } + if (selectedCol < maxCol) { selectedCol++; } else if (selectedRow < NUM_ROWS - 1) { @@ -162,35 +216,34 @@ bool KeyboardEntryActivity::handleInput() { selectedRow++; selectedCol = 0; } - handled = true; + updateRequired = true; } // Selection if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { handleKeyPress(); - handled = true; + updateRequired = true; } // Cancel if (inputManager.wasPressed(InputManager::BTN_BACK)) { - cancelled = true; if (onCancel) { onCancel(); } - handled = true; + updateRequired = true; } - - return handled; } -void KeyboardEntryActivity::render(int startY) const { - const auto pageWidth = GfxRenderer::getScreenWidth(); +void KeyboardEntryActivity::render() const { + const auto pageWidth = renderer.getScreenWidth(); + + renderer.clearScreen(); // Draw title renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); // Draw input field - int inputY = startY + 22; + const int inputY = startY + 22; renderer.drawText(UI_FONT_ID, 10, inputY, "["); std::string displayText; @@ -204,9 +257,9 @@ void KeyboardEntryActivity::render(int startY) const { displayText += "_"; // Truncate if too long for display - use actual character width from font - int charWidth = renderer.getSpaceWidth(UI_FONT_ID); - if (charWidth < 1) charWidth = 8; // Fallback to approximate width - int maxDisplayLen = (pageWidth - 40) / charWidth; + int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID); + if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width + const int maxDisplayLen = (pageWidth - 40) / approxCharWidth; if (displayText.length() > static_cast(maxDisplayLen)) { displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); } @@ -215,22 +268,22 @@ void KeyboardEntryActivity::render(int startY) const { renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); // Draw keyboard - use compact spacing to fit 5 rows on screen - int keyboardStartY = inputY + 25; - const int keyWidth = 18; - const int keyHeight = 18; - const int keySpacing = 3; + const int keyboardStartY = inputY + 25; + constexpr int keyWidth = 18; + constexpr int keyHeight = 18; + constexpr int keySpacing = 3; const char* const* layout = shiftActive ? keyboardShift : keyboard; // Calculate left margin to center the longest row (13 keys) - int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); - int leftMargin = (pageWidth - maxRowWidth) / 2; + constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); + const int leftMargin = (pageWidth - maxRowWidth) / 2; for (int row = 0; row < NUM_ROWS; row++) { - int rowY = keyboardStartY + row * (keyHeight + keySpacing); + const int rowY = keyboardStartY + row * (keyHeight + keySpacing); // Left-align all rows for consistent navigation - int startX = leftMargin; + const int startX = leftMargin; // Handle bottom row (row 4) specially with proper multi-column keys if (row == 4) { @@ -240,69 +293,53 @@ void KeyboardEntryActivity::render(int startY) const { int currentX = startX; // CAPS key (logical col 0, spans 2 key widths) - int capsWidth = 2 * keyWidth + keySpacing; - bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL); - if (capsSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps"); - currentX += capsWidth + keySpacing; + const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL); + renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected); + currentX += 2 * (keyWidth + keySpacing); // Space bar (logical cols 2-6, spans 5 key widths) - int spaceWidth = 5 * keyWidth + 4 * keySpacing; - bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); - if (spaceSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]"); - } - // Draw centered underscores for space bar - int spaceTextX = currentX + (spaceWidth / 2) - 12; - renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____"); - currentX += spaceWidth + keySpacing; + const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); + const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____"); + const int spaceXWidth = 5 * (keyWidth + keySpacing); + const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2; + renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected); + currentX += spaceXWidth; // Backspace key (logical col 7, spans 2 key widths) - int bsWidth = 2 * keyWidth + keySpacing; - bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL); - if (bsSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-"); - currentX += bsWidth + keySpacing; + const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL); + renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected); + currentX += 2 * (keyWidth + keySpacing); // OK button (logical col 9, spans 2 key widths) - int okWidth = 2 * keyWidth + keySpacing; - bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); - if (okSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK"); - + const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); + renderItemWithSelector(currentX + 2, rowY, "OK", okSelected); } else { // Regular rows: render each key individually for (int col = 0; col < getRowLength(row); col++) { - int keyX = startX + col * (keyWidth + keySpacing); - // Get the character to display - char c = layout[row][col]; + const char c = layout[row][col]; std::string keyLabel(1, c); + const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str()); - // Draw selection highlight - bool isSelected = (row == selectedRow && col == selectedCol); - - if (isSelected) { - renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]"); - } - - renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str()); + const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2; + const bool isSelected = row == selectedRow && col == selectedCol; + renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected); } } } // Draw help text at absolute bottom of screen (consistent with other screens) - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); + renderer.displayBuffer(); +} + +void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item, + const bool isSelected) const { + if (isSelected) { + const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item); + renderer.drawText(UI_FONT_ID, x - 6, y, "["); + renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]"); + } + renderer.drawText(UI_FONT_ID, x, y, item); } diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index 3b5b8063..552a3e8f 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -1,9 +1,13 @@ #pragma once #include #include +#include +#include +#include #include #include +#include #include "../Activity.h" @@ -30,80 +34,44 @@ class KeyboardEntryActivity : public Activity { * @param inputManager Reference to InputManager for handling input * @param title Title to display above the keyboard * @param initialText Initial text to show in the input field + * @param startY Y position to start rendering the keyboard * @param maxLength Maximum length of input text (0 for unlimited) * @param isPassword If true, display asterisks instead of actual characters + * @param onComplete Callback invoked when input is complete + * @param onCancel Callback invoked when input is cancelled */ - KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", - const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false) + explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text", + std::string initialText = "", const int startY = 10, const size_t maxLength = 0, + const bool isPassword = false, OnCompleteCallback onComplete = nullptr, + OnCancelCallback onCancel = nullptr) : Activity("KeyboardEntry", renderer, inputManager), - title(title), - text(initialText), + title(std::move(title)), + text(std::move(initialText)), + startY(startY), maxLength(maxLength), - isPassword(isPassword) {} - - /** - * Handle button input. Call this in your main loop. - * @return true if input was handled, false otherwise - */ - bool handleInput(); - - /** - * Render the keyboard at the specified Y position. - * @param startY Y-coordinate where keyboard rendering starts (default 10) - */ - void render(int startY = 10) const; - - /** - * Get the current text entered by the user. - */ - const std::string& getText() const { return text; } - - /** - * Set the current text. - */ - void setText(const std::string& newText); - - /** - * Check if the user has completed text entry (pressed OK on Done). - */ - bool isComplete() const { return complete; } - - /** - * Check if the user has cancelled text entry. - */ - bool isCancelled() const { return cancelled; } - - /** - * Reset the keyboard state for reuse. - */ - void reset(const std::string& newTitle = "", const std::string& newInitialText = ""); - - /** - * Set callback for when input is complete. - */ - void setOnComplete(OnCompleteCallback callback) { onComplete = callback; } - - /** - * Set callback for when input is cancelled. - */ - void setOnCancel(OnCancelCallback callback) { onCancel = callback; } + isPassword(isPassword), + onComplete(std::move(onComplete)), + onCancel(std::move(onCancel)) {} // Activity overrides void onEnter() override; + void onExit() override; void loop() override; private: std::string title; + int startY; std::string text; size_t maxLength; bool isPassword; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; // Keyboard state int selectedRow = 0; int selectedCol = 0; bool shiftActive = false; - bool complete = false; - bool cancelled = false; // Callbacks OnCompleteCallback onComplete; @@ -116,16 +84,17 @@ class KeyboardEntryActivity : public Activity { static const char* const keyboardShift[NUM_ROWS]; // Special key positions (bottom row) - static constexpr int SHIFT_ROW = 4; + static constexpr int SPECIAL_ROW = 4; static constexpr int SHIFT_COL = 0; - static constexpr int SPACE_ROW = 4; static constexpr int SPACE_COL = 2; - static constexpr int BACKSPACE_ROW = 4; static constexpr int BACKSPACE_COL = 7; - static constexpr int DONE_ROW = 4; static constexpr int DONE_COL = 9; + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); char getSelectedChar() const; void handleKeyPress(); int getRowLength(int row) const; + void render() const; + void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const; }; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 249c4570..7558305c 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -27,7 +27,12 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { } JsonDocument doc; - const DeserializationError error = deserializeJson(doc, *client); + JsonDocument filter; + filter["tag_name"] = true; + filter["assets"][0]["name"] = true; + filter["assets"][0]["browser_download_url"] = true; + filter["assets"][0]["size"] = true; + const DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter)); http.end(); if (error) { Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str());