diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3866016..21b09aaf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,18 @@ ## Summary -* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for - file uploading.) +* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * **What changes are included?** ## Additional Context -* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). +* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, + specific areas to focus on). + +--- + +### AI Usage + +While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it +helps set the right context for reviewers. + +Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be9a6e59..286f14aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ name: CI jobs: build: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/.github/workflows/pr-formatting-check.yml b/.github/workflows/pr-formatting-check.yml new file mode 100644 index 00000000..044b7b64 --- /dev/null +++ b/.github/workflows/pr-formatting-check.yml @@ -0,0 +1,26 @@ +name: "PR Formatting" + +on: + pull_request_target: + types: + - opened + - reopened + - edited + +permissions: + statuses: write + +jobs: + title-check: + name: Title Check + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Check PR Title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d1c830..df8d6679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,17 +7,18 @@ on: jobs: build-release: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/cache@v5 with: path: | ~/.cache/pip ~/.platformio/.cache key: ${{ runner.os }}-pio + - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1735ff82..0c852691 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -20,9 +20,10 @@ Button layout can be customized in **[Settings](#35-settings)**. ### Power On / Off -To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one. +To turn the device on or off, **press and hold the Power button for approximately half a second**. +In **[Settings](#35-settings)** you can configure the power button to turn the device off with a short press instead of a long one. -To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds. +To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then quickly press and hold the Power button for a few seconds. ### First Launch @@ -56,21 +57,36 @@ The File Upload screen allows you to upload new e-books to the device. When you See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. +> [!TIP] +> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: - **Sleep Screen**: Which sleep screen to display when the device sleeps: - - "Dark" (default) - The default dark sleep screen + - "Dark" (default) - The default dark Crosspoint logo sleep screen - "Light" - The same default sleep screen, on a white background - - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information + - "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) + - "None" - A blank screen +- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: + - "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary + - "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected) - **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress - "Full" - Show status bar with reading progress +- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown: + - "Never" - Always show battery percentage (default) + - "In Reader" - Show battery percentage everywhere except in reading mode + - "Always" - Always hide battery percentage - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book. If disabled, paragraphs will not have vertical space between them, but will have first-line indentation. -- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. -- **Reading Orientation**: Set the screen orientation for reading: +- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly. +- **Short Power Button Click**: Controls the effect of a short click of the power button: + - "Ignore" - Require a long press to turn off the device + - "Sleep" - A short press powers the device off + - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off +- **Reading Orientation**: Set the screen orientation for reading EPUB files: - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down @@ -79,16 +95,22 @@ The Settings screen allows you to configure the device's behavior. There are a f - Back, Confirm, Left, Right (default) - Left, Right, Back, Confirm - Left, Back, Confirm, Right -- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter. + - "Chapter Skip" (default) - Long-pressing skips to next/previous chapter + - "Page Scroll" - Long-pressing scrolls a page up/down +- Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. - **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font - "Open Dyslexic" - Font designed for readers with dyslexia - **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large". - **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide". +- **Reader Screen Margin**: Controls the screen margins in reader mode between 5 and 40 pixels in 5 pixel increments. - **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". - **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. - **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. +- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -120,10 +142,15 @@ Once you have opened a book, the button layout changes to facilitate reading. The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**. +If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button. + ### Chapter Navigation * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. +This feature can be disabled in **[Settings](#35-settings)** to help avoid changing chapters by mistake. + + ### System Navigation * **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen. * **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. diff --git a/docs/webserver.md b/docs/webserver.md index 2c96b8eb..2285a927 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -170,6 +170,40 @@ This is useful for organizing your ebooks by genre, author, or series. --- +## Command Line File Management + +For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. + +### Uploading a File +To upload a file to the root directory, use the following command: +```bash +curl -F "file=@book.epub" "http://crosspoint.local/upload?path=/" +``` + +* **`-F "file=@filename"`**: Points to the local file on your computer. +* **`path=/`**: The destination folder on the device SD card. + +### Deleting a File + +To delete a specific file, provide the full path on the SD card: + +```bash +curl -F "path=/folder/file.epub" "http://crosspoint.local/delete" +``` + +### Advanced Flags + +For more reliable transfers of large EPUB files, consider adding these flags: + +* `-#`: Shows a simple progress bar. +* `--connect-timeout 30`: Limits how long curl waits to establish a connection (in seconds). +* `--max-time 300`: Sets a maximum duration for the entire transfer (5 minutes). + +> [!NOTE] +> These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`). + +--- + ## Troubleshooting ### Cannot See the Device on the Network diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 234344d7..1b337721 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -167,7 +167,10 @@ bool Epub::parseTocNavFile() const { } const auto navSize = tempNavFile.size(); - TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get()); + // Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf + // and the HTMLX nav file will have hrefs relative to itself + const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1); + TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get()); if (!navParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); @@ -345,11 +348,14 @@ const std::string& Epub::getAuthor() const { return bookMetadataCache->coreMetadata.author; } -std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } +std::string Epub::getCoverBmpPath(bool cropped) const { + const auto coverFileName = "cover" + cropped ? "_crop" : ""; + return cachePath + "/" + coverFileName + ".bmp"; +} -bool Epub::generateCoverBmp() const { +bool Epub::generateCoverBmp(bool cropped) const { // Already generated, return true - if (SdMan.exists(getCoverBmpPath().c_str())) { + if (SdMan.exists(getCoverBmpPath(cropped).c_str())) { return true; } @@ -381,7 +387,7 @@ bool Epub::generateCoverBmp() const { } FsFile coverBmp; - if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { coverJpg.close(); return false; } @@ -392,7 +398,7 @@ bool Epub::generateCoverBmp() const { if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); - SdMan.remove(getCoverBmpPath().c_str()); + SdMan.remove(getCoverBmpPath(cropped).c_str()); } Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); return success; @@ -403,6 +409,70 @@ bool Epub::generateCoverBmp() const { return false; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Epub::generateThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); + return false; + } + + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + + if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + FsFile thumbBmp; + if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { + coverJpg.close(); + return false; + } + // Use smaller target size for Continue Reading card (half of screen: 240x400) + // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, + THUMB_TARGET_HEIGHT); + coverJpg.close(); + thumbBmp.close(); + SdMan.remove(coverJpgTempPath.c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), + success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); + } + + return false; +} + uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { if (itemHref.empty()) { Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index a6555e7e..91062aa4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -44,8 +44,10 @@ class Epub { const std::string& getPath() const; const std::string& getTitle() const; const std::string& getAuthor() const; - std::string getCoverBmpPath() const; - bool generateCoverBmp() const; + std::string getCoverBmpPath(bool cropped = false) const; + bool generateCoverBmp(bool cropped = false) const; + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 06b4f458..52e48098 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -9,7 +9,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 3; +constexpr uint8_t BOOK_CACHE_VERSION = 4; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f9c0326f..3c37e31b 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -43,7 +43,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(totalWordCount); // add em-space at the beginning of first word in paragraph to indent - if (!extraParagraphSpacing) { + if ((style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) && !extraParagraphSpacing) { std::string& first_word = words.front(); first_word.insert(0, "\xe2\x80\x83"); } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b96d28f8..acddd81d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -25,7 +25,7 @@ constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); -const char* SKIP_TAGS[] = {"head", "table"}; +const char* SKIP_TAGS[] = {"head"}; constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } @@ -63,13 +63,44 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } - if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { - // TODO: Start processing image tags + // Special handling for tables - show placeholder text instead of dropping silently + if (strcmp(name, "table") == 0) { + // Add placeholder text + self->startNewTextBlock(TextBlock::CENTER_ALIGN); + if (self->currentTextBlock) { + self->currentTextBlock->addWord("[Table omitted]", EpdFontFamily::ITALIC); + } + + // Skip table contents self->skipUntilDepth = self->depth; self->depth += 1; return; } + if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { + // TODO: Start processing image tags + std::string alt; + if (atts != nullptr) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "alt") == 0) { + alt = "[Image: " + std::string(atts[i + 1]) + "]"; + } + } + Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); + + self->startNewTextBlock(TextBlock::CENTER_ALIGN); + self->italicUntilDepth = min(self->italicUntilDepth, self->depth); + self->depth += 1; + self->characterData(userData, alt.c_str(), alt.length()); + + } else { + // Skip for now + self->skipUntilDepth = self->depth; + self->depth += 1; + return; + } + } + if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { // start skip self->skipUntilDepth = self->depth; @@ -97,6 +128,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); + if (strcmp(name, "li") == 0) { + self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + } } } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); @@ -151,6 +185,20 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char } } + // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF + const XML_Char FEFF_BYTE_1 = static_cast(0xEF); + const XML_Char FEFF_BYTE_2 = static_cast(0xBB); + const XML_Char FEFF_BYTE_3 = static_cast(0xBF); + + if (s[i] == FEFF_BYTE_1) { + // Check if the next two bytes complete the 3-byte sequence + if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) { + // Sequence 0xEF 0xBB 0xBF found! + i += 2; // Skip the next two bytes + continue; // Move to the next iteration + } + } + // If we're about to run out of space, then cut the word off and start a new one if (self->partWordBufferIndex >= MAX_WORD_SIZE) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 2c90d01d..aee7e57b 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -167,7 +167,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (strcmp(atts[i], "id") == 0) { itemId = atts[i + 1]; } else if (strcmp(atts[i], "href") == 0) { - href = self->baseContentPath + atts[i + 1]; + href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } else if (strcmp(atts[i], "media-type") == 0) { mediaType = atts[i + 1]; } else if (strcmp(atts[i], "properties") == 0) { @@ -243,7 +243,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name break; } } else if (strcmp(atts[i], "href") == 0) { - textHref = self->baseContentPath + atts[i + 1]; + textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } } if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { diff --git a/lib/Epub/Epub/parsers/TocNavParser.cpp b/lib/Epub/Epub/parsers/TocNavParser.cpp index b8a4e7fb..454b2437 100644 --- a/lib/Epub/Epub/parsers/TocNavParser.cpp +++ b/lib/Epub/Epub/parsers/TocNavParser.cpp @@ -1,5 +1,6 @@ #include "TocNavParser.h" +#include #include #include "../BookMetadataCache.h" @@ -140,7 +141,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) { if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) { // Create TOC entry when closing anchor tag (we have all data now) if (!self->currentLabel.empty() && !self->currentHref.empty()) { - std::string href = self->baseContentPath + self->currentHref; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index b1fbb2fe..3e59451e 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,5 +1,6 @@ #include "TocNcxParser.h" +#include #include #include "../BookMetadataCache.h" @@ -159,7 +160,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { // This is the safest place to push the data, assuming always comes before . // NCX spec says navLabel comes before content. if (!self->currentLabel.empty() && !self->currentSrc.empty()) { - std::string href = self->baseContentPath + self->currentSrc; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 8cc8a5f3..ad25ffcd 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -8,119 +8,15 @@ // ============================================================================ // 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 +constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg // ============================================================================ -// 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; + + delete atkinsonDitherer; + delete fsDitherer; } uint16_t Bitmap::readLE16(FsFile& f) { @@ -244,13 +140,14 @@ 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](); - prevRowY = -1; + // Create ditherer if enabled (only for 2-bit output) + // Use OUTPUT dimensions for dithering (after prescaling) + if (bpp > 2 && dithering) { + if (USE_ATKINSON) { + atkinsonDitherer = new AtkinsonDitherer(width); + } else { + fsDitherer = new FloydSteinbergDitherer(width); + } } return BmpReaderError::Ok; @@ -261,17 +158,6 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) 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) { - if (prevRowY != -1) { - // Sequential access - swap buffers - int16_t* temp = errorCurRow; - errorCurRow = errorNextRow; - errorNextRow = temp; - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - } - } prevRowY += 1; uint8_t* outPtr = data; @@ -282,12 +168,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { uint8_t color; - if (useFS) { - // Floyd-Steinberg error diffusion - color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); + if (atkinsonDitherer) { + color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + } else if (fsDitherer) { + color = fsDitherer->processPixel(adjustPixel(lum), currentX); } else { - // Simple quantization or noise dithering - color = quantize(lum, currentX, prevRowY); + if (bpp > 2) { + // Simple quantization or noise dithering + color = quantize(adjustPixel(lum), currentX, prevRowY); + } else { + // do not quantize 2bpp image + color = static_cast(lum >> 6); + } } currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -336,7 +228,10 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { } case 1: { for (int x = 0; x < width; x++) { - lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + // Get palette index (0 or 1) from bit at position x + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + // Use palette lookup for proper black/white mapping + lum = paletteLum[palIndex]; packPixel(lum); } break; @@ -345,6 +240,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { return BmpReaderError::UnsupportedBpp; } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + // Flush remaining bits if width is not a multiple of 4 if (bitShift != 6) *outPtr = currentOutByte; @@ -356,12 +256,9 @@ 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)); - prevRowY = -1; - } + // Reset dithering when rewinding + if (fsDitherer) fsDitherer->reset(); + if (atkinsonDitherer) atkinsonDitherer->reset(); return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index a3f2e00c..544869c1 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -2,6 +2,10 @@ #include +#include + +#include "BitmapHelpers.h" + enum class BmpReaderError : uint8_t { Ok = 0, FileInvalid, @@ -28,7 +32,7 @@ class Bitmap { public: static const char* errorToString(BmpReaderError err); - explicit Bitmap(FsFile& file) : file(file) {} + explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} ~Bitmap(); BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; @@ -38,12 +42,15 @@ class Bitmap { bool isTopDown() const { return topDown; } bool hasGreyscale() const { return bpp > 1; } int getRowBytes() const { return rowBytes; } + bool is1Bit() const { return bpp == 1; } + uint16_t getBpp() const { return bpp; } private: static uint16_t readLE16(FsFile& f); static uint32_t readLE32(FsFile& f); FsFile& file; + bool dithering = false; int width = 0; int height = 0; bool topDown = false; @@ -56,4 +63,7 @@ class Bitmap { mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorNextRow = nullptr; mutable int prevRowY = -1; // Track row progression for error propagation + + mutable AtkinsonDitherer* atkinsonDitherer = nullptr; + mutable FloydSteinbergDitherer* fsDitherer = nullptr; }; diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp new file mode 100644 index 00000000..465593e8 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -0,0 +1,106 @@ +#include "BitmapHelpers.h" + +#include + +// Brightness/Contrast adjustments: +constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering + +// 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 +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 - divide into 4 levels +// The thresholds are fine-tuned to the X4 display +uint8_t quantizeSimple(int gray) { + if (gray < 45) { + return 0; + } else if (gray < 70) { + return 1; + } else if (gray < 140) { + return 2; + } else { + return 3; + } +} + +// 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) { + 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 - selects between methods based on config +uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} + +// 1-bit noise dithering for fast home screen rendering +// Uses hash-based noise for consistent dithering that works well at small sizes +uint8_t quantize1bit(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 + + // Simple threshold with noise: gray >= (128 + noise offset) -> white + // The noise adds variation around the 128 midpoint + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + return (gray >= adjustedThreshold) ? 1 : 0; +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h new file mode 100644 index 00000000..791e70b9 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -0,0 +1,314 @@ +#pragma once + +#include + +// Helper functions +uint8_t quantize(int gray, int x, int y); +uint8_t quantizeSimple(int gray); +uint8_t quantize1bit(int gray, int x, int y); +int adjustPixel(int gray); + +// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails +// Error distribution pattern (same as 2-bit but quantizes to 2 levels): +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +class Atkinson1BitDitherer { + public: + explicit Atkinson1BitDitherer(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 + } + + ~Atkinson1BitDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + // EXPLICITLY DELETE THE COPY CONSTRUCTOR + Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete; + + // EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR + Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete; + + 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 2 levels (1-bit): 0 = black, 1 = white + uint8_t quantized; + int quantizedValue; + if (adjusted < 128) { + quantized = 0; + quantizedValue = 0; + } else { + quantized = 1; + 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; +}; + +// 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: + explicit 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; + } + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + AtkinsonDitherer(const AtkinsonDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete; + + uint8_t processPixel(int gray, int x) { + // 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 (false) { // original thresholds + 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; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // 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: + explicit 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; + } + + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete; + + // 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) { + // 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 (false) { // original thresholds + 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; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!isReverseRow()) { + // 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; +}; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index cc1288a7..7072fed8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -154,6 +154,12 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { + // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) + if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) { + drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight); + return; + } + float scale = 1.0f; bool isScaled = false; int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); @@ -195,6 +201,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenY >= getScreenHeight()) { break; } + if (screenY < 0) { + continue; + } if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); @@ -217,6 +226,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenX >= getScreenWidth()) { break; } + if (screenX < 0) { + continue; + } const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; @@ -234,6 +246,143 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } +void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, + const int maxHeight) const { + float scale = 1.0f; + bool isScaled = false; + if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + // For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow) + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + // Read rows sequentially using readNextRow + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { + Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); + free(outputRow); + free(rowBytes); + return; + } + + // Calculate screen Y based on whether BMP is top-down or bottom-up + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); + if (screenY >= getScreenHeight()) { + continue; // Continue reading to keep row counter in sync + } + if (screenY < 0) { + continue; + } + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); + if (screenX >= getScreenWidth()) { + break; + } + if (screenX < 0) { + continue; + } + + // Get 2-bit value (result of readNextRow quantization) + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) + // val < 3 means black pixel (draw it) + if (val < 3) { + drawPixel(screenX, screenY, true); + } + // White pixels (val == 3) are not drawn (leave background) + } + } + + free(outputRow); + free(rowBytes); +} + +void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { + if (numPoints < 3) return; + + // Find bounding box + int minY = yPoints[0], maxY = yPoints[0]; + for (int i = 1; i < numPoints; i++) { + if (yPoints[i] < minY) minY = yPoints[i]; + if (yPoints[i] > maxY) maxY = yPoints[i]; + } + + // Clip to screen + if (minY < 0) minY = 0; + if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1; + + // Allocate node buffer for scanline algorithm + auto* nodeX = static_cast(malloc(numPoints * sizeof(int))); + if (!nodeX) { + Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); + return; + } + + // Scanline fill algorithm + for (int scanY = minY; scanY <= maxY; scanY++) { + int nodes = 0; + + // Find all intersection points with edges + int j = numPoints - 1; + for (int i = 0; i < numPoints; i++) { + if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) { + // Calculate X intersection using fixed-point to avoid float + int dy = yPoints[j] - yPoints[i]; + if (dy != 0) { + nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy; + } + } + j = i; + } + + // Sort nodes by X (simple bubble sort, numPoints is small) + for (int i = 0; i < nodes - 1; i++) { + for (int k = i + 1; k < nodes; k++) { + if (nodeX[i] > nodeX[k]) { + int temp = nodeX[i]; + nodeX[i] = nodeX[k]; + nodeX[k] = temp; + } + } + } + + // Fill between pairs of nodes + for (int i = 0; i < nodes - 1; i += 2) { + int startX = nodeX[i]; + int endX = nodeX[i + 1]; + + // Clip to screen + if (startX < 0) startX = 0; + if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; + + // Draw horizontal line + for (int x = startX; x <= endX; x++) { + drawPixel(x, scanY, state); + } + } + } + + free(nodeX); +} + void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::invertScreen() const { @@ -319,7 +468,10 @@ int GfxRenderer::getLineHeight(const int fontId) const { } void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, - const char* btn4) const { + const char* btn4) { + const Orientation orig_orientation = getOrientation(); + setOrientation(Orientation::Portrait); + const int pageHeight = getScreenHeight(); constexpr int buttonWidth = 106; constexpr int buttonHeight = 40; @@ -332,12 +484,15 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char // Only draw if the label is non-empty if (labels[i] != nullptr && labels[i][0] != '\0') { const int x = buttonPositions[i]; + fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); const int textWidth = getTextWidth(fontId, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); } } + + setOrientation(orig_orientation); } void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e3e9558d..b1fea69b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -68,6 +68,8 @@ class GfxRenderer { void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const; + void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; @@ -82,7 +84,7 @@ class GfxRenderer { EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; // UI Components - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; + void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; private: @@ -97,8 +99,8 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 8c8db889..01451a05 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -7,6 +7,8 @@ #include #include +#include "BitmapHelpers.h" + // Context structure for picojpeg callback struct JpegReadContext { FsFile& file; @@ -23,282 +25,12 @@ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantizati 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(value & 0xFF); out.write((value >> 8) & 0xFF); @@ -355,8 +87,47 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { } } +// Helper function: Write BMP header with 1-bit color depth (black and white) +static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8) + + // 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, 1); // Bits per pixel (1 bit) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 2); // colorsUsed + write32(bmpOut, 2); // colorsImportant + + // Color Palette (2 colors x 4 bytes = 8 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + // Note: In 1-bit BMP, palette index 0 = black, 1 = white + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + // Helper function: Write BMP header with 2-bit color depth -void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { +static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up const int imageSize = bytesPerRow * height; @@ -427,9 +198,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Core function: Convert JPEG file to 2-bit BMP -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); +// Internal implementation with configurable target size and bit depth +bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit) { + Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", + targetWidth, targetHeight); // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -464,10 +237,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) { // 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 scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; // We scale to the smaller dimension, so we can potentially crop later. // TODO: ideally, we already crop here. const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; @@ -486,16 +259,19 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { 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); + imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); } // Write BMP header with output dimensions int bytesPerRow; - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; + } else if (oneBit) { + writeBmpHeader1bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel } else { - writeBmpHeader(bmpOut, outWidth, outHeight); + writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } @@ -526,11 +302,16 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { return false; } - // Create ditherer if enabled (only for 2-bit output) + // Create ditherer if enabled // Use OUTPUT dimensions for dithering (after prescaling) AtkinsonDitherer* atkinsonDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr; - if (!USE_8BIT_OUTPUT) { + Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; + + if (oneBit) { + // For 1-bit output, use Atkinson dithering for better quality + atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(outWidth); } else if (USE_FLOYD_STEINBERG) { @@ -616,19 +397,32 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { // No scaling - direct output (1:1 mapping) memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; rowBuffer[x] = adjustPixel(gray); } - } else { + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t bit = + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); + } else { + // 2-bit output + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = adjustPixel(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()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, y); } @@ -679,19 +473,32 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; rowBuffer[x] = adjustPixel(gray); } - } else { + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, currentOutY); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); + } else { + // 2-bit output + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = adjustPixel((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()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, currentOutY); } @@ -732,9 +539,29 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (fsDitherer) { delete fsDitherer; } + if (atkinson1BitDitherer) { + delete atkinson1BitDitherer; + } free(mcuRowBuffer); free(rowBuffer); Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); return true; } + +// Core function: Convert JPEG file to 2-bit BMP (uses default target size) +bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); +} + +// Convert with custom target size (for thumbnails, 2-bit) +bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false); +} + +// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering +bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index f61bd8ef..d5e9b950 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,11 +5,15 @@ class Print; class ZipFile; class JpegToBmpConverter { - static void writeBmpHeader(Print& bmpOut, int width, int height); - // [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); + static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit); public: static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + // Convert with custom target size (for thumbnails) + static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); + // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering + static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); }; diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp new file mode 100644 index 00000000..52c75ed7 --- /dev/null +++ b/lib/Txt/Txt.cpp @@ -0,0 +1,191 @@ +#include "Txt.h" + +#include +#include + +Txt::Txt(std::string path, std::string cacheBasePath) + : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { + // Generate cache path from file path hash + const size_t hash = std::hash{}(filepath); + cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash); +} + +bool Txt::load() { + if (loaded) { + return true; + } + + if (!SdMan.exists(filepath.c_str())) { + Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); + return false; + } + + fileSize = file.size(); + file.close(); + + loaded = true; + Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); + return true; +} + +std::string Txt::getTitle() const { + // Extract filename without path and extension + size_t lastSlash = filepath.find_last_of('/'); + std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; + + // Remove .txt extension + if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") { + filename = filename.substr(0, filename.length() - 4); + } + + return filename; +} + +void Txt::setupCacheDir() const { + if (!SdMan.exists(cacheBasePath.c_str())) { + SdMan.mkdir(cacheBasePath.c_str()); + } + if (!SdMan.exists(cachePath.c_str())) { + SdMan.mkdir(cachePath.c_str()); + } +} + +std::string Txt::findCoverImage() const { + // Get the folder containing the txt file + size_t lastSlash = filepath.find_last_of('/'); + std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : ""; + if (folder.empty()) { + folder = "/"; + } + + // Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt") + std::string baseName = getTitle(); + + // Image extensions to try + const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"}; + + // First priority: look for image with same name as txt file (e.g., mybook.jpg) + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + baseName + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + + // Fallback: look for cover image files + const char* coverNames[] = {"cover", "Cover", "COVER"}; + for (const auto& name : coverNames) { + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + std::string(name) + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + } + + return ""; +} + +std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Txt::generateCoverBmp() const { + // Already generated, return true + if (SdMan.exists(getCoverBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get file extension + const size_t len = coverImagePath.length(); + const bool isJpg = + (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || + (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); + const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP"); + + if (isBmp) { + // Copy BMP file to cache + Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); + FsFile src, dst; + if (!SdMan.openFileForRead("TXT", coverImagePath, src)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) { + src.close(); + return false; + } + uint8_t buffer[1024]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + src.close(); + dst.close(); + Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); + return true; + } + + if (isJpg) { + // Convert JPG/JPEG to BMP (same approach as Epub) + Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); + FsFile coverJpg, coverBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); + SdMan.remove(getCoverBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); + } + return success; + } + + // PNG files are not supported (would need a PNG decoder) + Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); + return false; +} + +bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { + if (!loaded) { + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + return false; + } + + if (!file.seek(offset)) { + file.close(); + return false; + } + + size_t bytesRead = file.read(buffer, length); + file.close(); + + return bytesRead > 0; +} diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h new file mode 100644 index 00000000..b75c7738 --- /dev/null +++ b/lib/Txt/Txt.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include + +class Txt { + std::string filepath; + std::string cacheBasePath; + std::string cachePath; + bool loaded = false; + size_t fileSize = 0; + + public: + explicit Txt(std::string path, std::string cacheBasePath); + + bool load(); + [[nodiscard]] const std::string& getPath() const { return filepath; } + [[nodiscard]] const std::string& getCachePath() const { return cachePath; } + [[nodiscard]] std::string getTitle() const; + [[nodiscard]] size_t getFileSize() const { return fileSize; } + + void setupCacheDir() const; + + // Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file + [[nodiscard]] std::string getCoverBmpPath() const; + [[nodiscard]] bool generateCoverBmp() const; + [[nodiscard]] std::string findCoverImage() const; + + // Read content from file + [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; +}; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 8f79c9dd..7205ffb9 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -293,6 +293,267 @@ bool Xtc::generateCoverBmp() const { return true; } +std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Xtc::generateThumbBmp() const { + // Already generated + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate thumb 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(); + + // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + + // Calculate scale factor + float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; + float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + // Only scale down, never up + if (scale >= 1.0f) { + // Page is already small enough, just use cover.bmp + // Copy cover.bmp to thumb.bmp + if (generateCoverBmp()) { + FsFile src, dst; + if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { + uint8_t buffer[512]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + dst.close(); + } + src.close(); + } + Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); + return SdMan.exists(getThumbBmpPath().c_str()); + } + return false; + } + + uint16_t thumbWidth = static_cast(pageInfo.width * scale); + uint16_t thumbHeight = static_cast(pageInfo.height * scale); + + Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, + pageInfo.height, thumbWidth, thumbHeight, scale); + + // Allocate buffer for page data + 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 for thumb\n", millis()); + free(pageBuffer); + return false; + } + + // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) + FsFile thumbBmp; + if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { + Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write 1-bit BMP header for fast home screen rendering + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes + const uint32_t imageSize = rowSize * thumbHeight; + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette + + // File header + thumbBmp.write('B'); + thumbBmp.write('M'); + thumbBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + thumbBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) + thumbBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header + uint32_t dibHeaderSize = 40; + thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t widthVal = thumbWidth; + thumbBmp.write(reinterpret_cast(&widthVal), 4); + int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down + thumbBmp.write(reinterpret_cast(&heightVal), 4); + uint16_t planes = 1; + thumbBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 1; // 1-bit for black and white + thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; + thumbBmp.write(reinterpret_cast(&compression), 4); + thumbBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; + thumbBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + thumbBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 2; + thumbBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 2; + thumbBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette (2 colors for 1-bit: black and white) + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + thumbBmp.write(palette, 8); + + // Allocate row buffer for 1-bit output + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + free(pageBuffer); + thumbBmp.close(); + return false; + } + + // Fixed-point scale factor (16.16) + uint32_t scaleInv_fp = static_cast(65536.0f / scale); + + // Pre-calculate plane info for 2-bit mode + const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; + const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; + const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; + const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; + const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; + + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) + + // Calculate source Y range with bounds checking + uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; + uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; + if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + // Calculate source X range with bounds checking + uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; + uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; + if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + + // Area averaging: sum grayscale values (0-255 range) + uint32_t graySum = 0; + uint32_t totalCount = 0; + + for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { + for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { + uint8_t grayValue = 255; // Default: white + + if (bitDepth == 2) { + // XTH 2-bit mode: pixel value 0-3 + // Bounds check for column index + if (srcX < pageInfo.width) { + const size_t colIndex = pageInfo.width - 1 - srcX; + const size_t byteInCol = srcY / 8; + const size_t bitInByte = 7 - (srcY % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + // Bounds check for buffer access + if (byteOffset < planeSize) { + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + const uint8_t pixelValue = (bit1 << 1) | bit2; + // Convert 2-bit (0-3) to grayscale: 0=black, 3=white + // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) + grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 + } + } + } else { + // 1-bit mode + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + const size_t bitIdx = 7 - (srcX % 8); + // Bounds check for buffer access + if (byteIdx < bitmapSize) { + const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; + // XTC polarity: 1=black, 0=white + grayValue = pixelBit ? 0 : 255; + } + } + + graySum += grayValue; + totalCount++; + } + } + + // Calculate average grayscale and quantize to 1-bit with noise dithering + uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; + + // Hash-based noise dithering for 1-bit output + uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + + // Quantize to 1-bit: 0=black, 1=white + uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; + + // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) + const size_t byteIndex = dstX / 8; + const size_t bitOffset = 7 - (dstX % 8); + // Bounds check for row buffer access + if (byteIndex < rowSize) { + if (oneBit) { + rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white + } else { + rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black + } + } + } + + // Write row (already padded to 4-byte boundary by rowSize) + thumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + thumbBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, + getThumbBmpPath().c_str()); + return true; +} + uint32_t Xtc::getPageCount() const { if (!loaded || !parser) { return 0; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index e5bce102..7413ef47 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -62,6 +62,9 @@ class Xtc { // Cover image support (for sleep screen) std::string getCoverBmpPath() const; bool generateCoverBmp() const; + // Thumbnail support (for Continue Reading card) + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; // Page access uint32_t getPageCount() const; diff --git a/platformio.ini b/platformio.ini index 75d1a77b..ef27ffd5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,9 @@ [platformio] -crosspoint_version = 0.12.0 default_envs = default +[crosspoint] +version = 0.14.0 + [base] platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 @@ -43,17 +45,18 @@ lib_deps = InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager - ArduinoJson @ 7.4.2 - QRCode @ 0.0.1 + bblanchon/ArduinoJson @ 7.4.2 + ricmoo/QRCode @ 0.0.1 + links2004/WebSockets @ 2.7.3 [env:default] extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" [env:gh_release] extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}\" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 80ced05e..2312ec06 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 18; +constexpr uint8_t SETTINGS_COUNT = 20; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -46,6 +46,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); + serialization::writePod(outputFile, hideBatteryPercentage); + serialization::writePod(outputFile, longPressChapterSkip); serialization::writeString(outputFile, std::string(selectedSleepBmp)); outputFile.close(); @@ -109,15 +111,20 @@ bool CrossPointSettings::loadFromFile() { strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; } + if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hideBatteryPercentage); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, longPressChapterSkip); + if (++settingsRead >= fileSettingsCount) break; { std::string bmpStr; serialization::readString(inputFile, bmpStr); strncpy(selectedSleepBmp, bmpStr.c_str(), sizeof(selectedSleepBmp) - 1); selectedSleepBmp[sizeof(selectedSleepBmp) - 1] = '\0'; } - if (++settingsRead >= fileSettingsCount) break; + ++settingsRead; // Final increment (should equal fileSettingsCount for current version) } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index b79d4e97..f0a1a676 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -52,6 +52,12 @@ class CrossPointSettings { // E-ink refresh frequency (pages between full refreshes) enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + // Short power button press actions + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + + // Hide battery percentage + enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -61,8 +67,8 @@ class CrossPointSettings { // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; - // Duration of the power button press - uint8_t shortPwrBtn = 0; + // Short power button click behaviour + uint8_t shortPwrBtn = IGNORE; // EPUB reading orientation settings // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise uint8_t orientation = PORTRAIT; @@ -82,6 +88,10 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + // Hide battery percentage + uint8_t hideBatteryPercentage = HIDE_NEVER; + // Long-press chapter skip on side buttons + uint8_t longPressChapterSkip = 1; // Selected sleep BMP filename (empty means random selection) char selectedSleepBmp[256] = ""; @@ -90,7 +100,9 @@ class CrossPointSettings { // Get singleton instance static CrossPointSettings& getInstance() { return instance; } - uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } + uint16_t getPowerButtonDuration() const { + return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400; + } int getReaderFontId() const; bool saveToFile() const; diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 31cb2acb..91aa2536 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 1; +constexpr uint8_t STATE_FILE_VERSION = 2; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const { serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); + serialization::writePod(outputFile, lastSleepImage); outputFile.close(); return true; } @@ -31,13 +32,18 @@ bool CrossPointState::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); - if (version != STATE_FILE_VERSION) { + if (version > STATE_FILE_VERSION) { Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); inputFile.close(); return false; } serialization::readString(inputFile, openEpubPath); + if (version >= 2) { + serialization::readPod(inputFile, lastSleepImage); + } else { + lastSleepImage = 0; + } inputFile.close(); return true; diff --git a/src/CrossPointState.h b/src/CrossPointState.h index f060a0c6..87ce4e96 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -8,6 +8,7 @@ class CrossPointState { public: std::string openEpubPath; + uint8_t lastSleepImage; ~CrossPointState() = default; // Get singleton instance diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index 3c359c0e..42b6ef7b 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -8,10 +8,11 @@ #include "Battery.h" #include "fontIds.h" -void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) { +void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, + const bool showPercentage) { // Left aligned battery icon and percentage const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; + const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); // 1 column on left, 2 columns on right, 5 columns of battery body diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index d938beea..150fb0c8 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -7,7 +7,7 @@ class GfxRenderer; class ScreenComponents { public: - static void drawBattery(const GfxRenderer& renderer, int left, int top); + static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); /** * Draw a progress bar with percentage text. diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index f025fa2a..2bd04a6d 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "CrossPointSettings.h" @@ -95,13 +96,20 @@ void SleepActivity::renderCustomSleepScreen() const { } const auto numFiles = files.size(); if (numFiles > 0) { - const auto randomFileIndex = random(numFiles); + // Generate a random number between 1 and numFiles + auto randomFileIndex = random(numFiles); + // If we picked the same image as last time, reroll + while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) { + randomFileIndex = random(numFiles); + } + APP_STATE.lastSleepImage = randomFileIndex; + APP_STATE.saveToFile(); const auto filename = "/sleep/" + files[randomFileIndex]; FsFile file; if (SdMan.openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); dir.close(); @@ -116,7 +124,7 @@ void SleepActivity::renderCustomSleepScreen() const { // render a custom sleep screen instead of the default. FsFile file; if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); renderBitmapSleepScreen(bitmap); @@ -214,7 +222,9 @@ void SleepActivity::renderCoverSleepScreen() const { } std::string coverBmpPath; + bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; + // Check if the current book is XTC, TXT, or EPUB if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { // Handle XTC file @@ -230,6 +240,20 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { + // Handle TXT file - looks for cover image in the same folder + Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastTxt.load()) { + Serial.println("[SLP] Failed to load last TXT"); + return renderDefaultSleepScreen(); + } + + if (!lastTxt.generateCoverBmp()) { + Serial.println("[SLP] No cover image found for TXT file"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastTxt.getCoverBmpPath(); } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); @@ -238,12 +262,12 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - if (!lastEpub.generateCoverBmp()) { + if (!lastEpub.generateCoverBmp(cropped)) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); } - coverBmpPath = lastEpub.getCoverBmpPath(); + coverBmpPath = lastEpub.getCoverBmpPath(cropped); } else { return renderDefaultSleepScreen(); } diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index b9dbac8e..4e0a08d2 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,7 +7,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "ScreenComponents.h" -#include "WifiCredentialStore.h" +#include "activities/network/WifiSelectionActivity.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/StringUtils.h" @@ -25,7 +25,7 @@ void OpdsBookBrowserActivity::taskTrampoline(void* param) { } void OpdsBookBrowserActivity::onEnter() { - Activity::onEnter(); + ActivityWithSubactivity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); state = BrowserState::CHECK_WIFI; @@ -49,7 +49,7 @@ void OpdsBookBrowserActivity::onEnter() { } void OpdsBookBrowserActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); @@ -66,13 +66,28 @@ void OpdsBookBrowserActivity::onExit() { } void OpdsBookBrowserActivity::loop() { + // Handle WiFi selection subactivity + if (state == BrowserState::WIFI_SELECTION) { + ActivityWithSubactivity::loop(); + return; + } + // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - state = BrowserState::LOADING; - statusMessage = "Loading..."; - updateRequired = true; - fetchFeed(currentPath); + // Check if WiFi is still connected + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + // WiFi connected - just retry fetching the feed + Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis()); + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else { + // WiFi not connected - launch WiFi selection + Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis()); + launchWifiSelection(); + } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } @@ -350,8 +365,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { } void OpdsBookBrowserActivity::checkAndConnectWifi() { - // Already connected? - if (WiFi.status() == WL_CONNECTED) { + // Already connected? Verify connection is valid by checking IP + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = "Loading..."; updateRequired = true; @@ -359,38 +374,33 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { return; } - // Try to connect using saved credentials - statusMessage = "Connecting to WiFi..."; + // Not connected - launch WiFi selection screen directly + launchWifiSelection(); +} + +void OpdsBookBrowserActivity::launchWifiSelection() { + state = BrowserState::WIFI_SELECTION; updateRequired = true; - WIFI_STORE.loadFromFile(); - const auto& credentials = WIFI_STORE.getCredentials(); - if (credentials.empty()) { - state = BrowserState::ERROR; - errorMessage = "No WiFi credentials saved"; - updateRequired = true; - return; - } + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} - // Use the first saved credential - const auto& cred = credentials[0]; - WiFi.mode(WIFI_STA); - WiFi.begin(cred.ssid.c_str(), cred.password.c_str()); +void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { + exitActivity(); - // Wait for connection with timeout - constexpr int WIFI_TIMEOUT_MS = 10000; - const unsigned long startTime = millis(); - while (WiFi.status() != WL_CONNECTED && millis() - startTime < WIFI_TIMEOUT_MS) { - vTaskDelay(100 / portTICK_PERIOD_MS); - } - - if (WiFi.status() == WL_CONNECTED) { - Serial.printf("[%lu] [OPDS] WiFi connected: %s\n", millis(), WiFi.localIP().toString().c_str()); + if (connected) { + Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis()); state = BrowserState::LOADING; statusMessage = "Loading..."; updateRequired = true; fetchFeed(currentPath); } else { + Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis()); + // Force disconnect to ensure clean state for next retry + // This prevents stale connection status from interfering + WiFi.disconnect(); + WiFi.mode(WIFI_OFF); state = BrowserState::ERROR; errorMessage = "WiFi connection failed"; updateRequired = true; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index efda294c..b08d9c2a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -8,25 +8,27 @@ #include #include -#include "../Activity.h" +#include "../ActivityWithSubactivity.h" /** * Activity for browsing and downloading books from an OPDS server. * Supports navigation through catalog hierarchy and downloading EPUBs. + * When WiFi connection fails, launches WiFi selection to let user connect. */ -class OpdsBookBrowserActivity final : public Activity { +class OpdsBookBrowserActivity final : public ActivityWithSubactivity { public: enum class BrowserState { - CHECK_WIFI, // Checking WiFi connection - LOADING, // Fetching OPDS feed - BROWSING, // Displaying entries (navigation or books) - DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message + CHECK_WIFI, // Checking WiFi connection + WIFI_SELECTION, // WiFi selection subactivity is active + LOADING, // Fetching OPDS feed + BROWSING, // Displaying entries (navigation or books) + DOWNLOADING, // Downloading selected EPUB + ERROR // Error state with message }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome) - : Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; @@ -54,6 +56,8 @@ class OpdsBookBrowserActivity final : public Activity { void render() const; void checkAndConnectWifi(); + void launchWifiSelection(); + void onWifiSelectionComplete(bool connected); void fetchFeed(const std::string& path); void navigateToEntry(const OpdsEntry& entry); void navigateBack(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index a774780e..3a97e132 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,12 +1,15 @@ #include "HomeActivity.h" +#include #include #include #include +#include #include #include +#include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -45,7 +48,7 @@ void HomeActivity::onEnter() { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - // If epub, try to load the metadata for title/author + // If epub, try to load the metadata for title/author and cover if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); @@ -55,10 +58,31 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { - lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - lastBookTitle.resize(lastBookTitle.length() - 4); + // Try to generate thumbnail image for Continue Reading card + if (epub.generateThumbBmp()) { + coverBmpPath = epub.getThumbBmpPath(); + hasCoverImage = true; + } + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || + StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + // Handle XTC file + Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) { + lastBookTitle = std::string(xtc.getTitle()); + } + // Try to generate thumbnail image for Continue Reading card + if (xtc.generateThumbBmp()) { + coverBmpPath = xtc.getThumbBmpPath(); + hasCoverImage = true; + } + } + // Remove extension from title if we don't have metadata + if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + lastBookTitle.resize(lastBookTitle.length() - 4); + } } } @@ -68,7 +92,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 2048, // Stack size + 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -86,6 +110,51 @@ void HomeActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + // Free the stored cover buffer if any + freeCoverBuffer(); +} + +bool HomeActivity::storeCoverBuffer() { + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + // Free any existing buffer first + freeCoverBuffer(); + + const size_t bufferSize = GfxRenderer::getBufferSize(); + coverBuffer = static_cast(malloc(bufferSize)); + if (!coverBuffer) { + return false; + } + + memcpy(coverBuffer, frameBuffer, bufferSize); + return true; +} + +bool HomeActivity::restoreCoverBuffer() { + if (!coverBuffer) { + return false; + } + + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + const size_t bufferSize = GfxRenderer::getBufferSize(); + memcpy(frameBuffer, coverBuffer, bufferSize); + return true; +} + +void HomeActivity::freeCoverBuffer() { + if (coverBuffer) { + free(coverBuffer); + coverBuffer = nullptr; + } + coverBufferStored = false; } void HomeActivity::loop() { @@ -137,8 +206,12 @@ void HomeActivity::displayTaskLoop() { } } -void HomeActivity::render() const { - renderer.clearScreen(); +void HomeActivity::render() { + // If we have a stored cover buffer, restore it instead of clearing + const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); + if (!bufferRestored) { + renderer.clearScreen(); + } const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -153,34 +226,101 @@ void HomeActivity::render() const { constexpr int bookY = 30; const bool bookSelected = hasContinueReading && selectorIndex == 0; + // Bookmark dimensions (used in multiple places) + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + // Draw book card regardless, fill with message based on `hasContinueReading` { - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate position to center image within the book card + int coverX, coverY; + + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { + const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + + if (imgRatio > boxRatio) { + coverX = bookX; + coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; + } else { + coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; + coverY = bookY; + } + } else { + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; + } + + // Draw the cover image centered within the book card + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + + // Draw border around the card + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + + // No bookmark ribbon when cover is shown - it would just cover the art + + // Store the buffer with cover image for fast navigation + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + + // First render: if selected, draw selection indicators now + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } + } + file.close(); + } + } else if (!bufferRestored && !coverRendered) { + // No cover image: draw border or fill, plus bookmark as visual flair + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + } + + // Draw bookmark ribbon when no cover image (visual decoration) + if (hasContinueReading) { + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); + } } - // Bookmark icon in the top-right corner of the card - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; - constexpr int bookmarkY = bookY + 1; - - // Main bookmark body (solid) - renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); - - // Carve out an inverted triangle notch at the bottom center to create angled points - const int notchHeight = bookmarkHeight / 2; // depth of the notch - for (int i = 0; i < notchHeight; ++i) { - const int y = bookmarkY + bookmarkHeight - 1 - i; - const int xStart = bookmarkX + i; - const int width = bookmarkWidth - 2 * i; - if (width <= 0) { - break; - } - // Draw a horizontal strip in the opposite color to "cut" the notch - renderer.fillRect(xStart, y, width, 1, bookSelected); + // If buffer was restored, draw selection indicators if needed + if (bufferRestored && bookSelected && coverRendered) { + // Draw selection border (no bookmark inversion needed since cover has no bookmark) + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } else if (!coverRendered && !bufferRestored) { + // Selection border already handled above in the no-cover case } } @@ -217,18 +357,25 @@ void HomeActivity::render() const { lines.back().append("..."); while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - lines.back().resize(lines.back().size() - 5); + // Remove "..." first, then remove one UTF-8 char, then add "..." back + lines.back().resize(lines.back().size() - 3); // Remove "..." + StringUtils::utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; } int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && i.size() > 5) { - // Word itself is too long, trim it - i.resize(i.size() - 5); - i.append("..."); - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && !i.empty()) { + // Word itself is too long, trim it (UTF-8 safe) + StringUtils::utf8RemoveLastChar(i); + // Check if we have room for ellipsis + std::string withEllipsis = i + "..."; + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); + if (wordWidth <= maxLineWidth) { + i = withEllipsis; + break; + } } int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); @@ -260,24 +407,85 @@ void HomeActivity::render() const { // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + // If cover image was rendered, draw white box behind title and author + if (coverRendered) { + constexpr int boxPadding = 8; + // Calculate the max text width for the box + int maxTextWidth = 0; + for (const auto& line : lines) { + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + if (lineWidth > maxTextWidth) { + maxTextWidth = lineWidth; + } + } + if (!lastBookAuthor.empty()) { + std::string trimmedAuthor = lastBookAuthor; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { + trimmedAuthor.append("..."); + } + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (authorWidth > maxTextWidth) { + maxTextWidth = authorWidth; + } + } + + const int boxWidth = maxTextWidth + boxPadding * 2; + const int boxHeight = totalTextHeight + boxPadding * 2; + const int boxX = (pageWidth - boxWidth) / 2; + const int boxY = titleYStart - boxPadding; + + // Draw white filled box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + // Draw black border around the box + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, true); + } + for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected || coverRendered); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long + // Trim author if too long (UTF-8 safe) + bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - trimmedAuthor.resize(trimmedAuthor.size() - 5); + StringUtils::utf8RemoveLastChar(trimmedAuthor); + wasTrimmed = true; + } + if (wasTrimmed && !trimmedAuthor.empty()) { + // Make room for ellipsis + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && + !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } trimmedAuthor.append("..."); } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected || coverRendered); } - renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, - "Continue Reading", !bookSelected); + // "Continue Reading" label at the bottom + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + if (coverRendered) { + // Draw white box behind "Continue Reading" text + const char* continueText = "Continue Reading"; + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); + constexpr int continuePadding = 6; + const int continueBoxWidth = continueTextWidth + continuePadding * 2; + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; + const int continueBoxX = (pageWidth - continueBoxWidth) / 2; + const int continueBoxY = continueY - continuePadding / 2; + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, false); + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, true); + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, true); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); + } } else { // No book to continue reading const int y = @@ -332,7 +540,13 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - ScreenComponents::drawBattery(renderer, 20, pageHeight - 70); + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + // get percentage so we can align text properly + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; + const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); renderer.displayBuffer(); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 84cb5bfd..68af0591 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -14,8 +14,13 @@ class HomeActivity final : public Activity { bool updateRequired = false; bool hasContinueReading = false; bool hasOpdsUrl = false; + bool hasCoverImage = false; + bool coverRendered = false; // Track if cover has been rendered once + bool coverBufferStored = false; // Track if cover buffer is stored + uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image std::string lastBookTitle; std::string lastBookAuthor; + std::string coverBmpPath; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; @@ -24,8 +29,11 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); - void render() const; + void render(); int getMenuItemCount() const; + bool storeCoverBuffer(); // Store frame buffer for cover image + bool restoreCoverBuffer(); // Restore frame buffer from stored cover + void freeCoverBuffer(); // Free the stored cover buffer public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index dde05614..35ad58ba 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() { dnsServer = nullptr; } - // CRITICAL: Wait for LWIP stack to flush any pending packets - Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); - delay(500); + // Brief wait for LWIP stack to flush pending packets + delay(50); // Disconnect WiFi gracefully if (isApMode) { @@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame } - delay(100); // Allow disconnect frame to be sent + delay(30); // Allow disconnect frame to be sent Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); - delay(100); // Allow WiFi hardware to fully power down + delay(30); // Allow WiFi hardware to power down Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() { dnsServer->processNextRequest(); } - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput + // STA mode: Monitor WiFi connection health + if (!isApMode && webServer && webServer->isRunning()) { + static unsigned long lastWifiCheck = 0; + if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds + lastWifiCheck = millis(); + const wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus != WL_CONNECTED) { + Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); + // Show error and exit gracefully + state = WebServerActivityState::SHUTTING_DOWN; + updateRequired = true; + return; + } + // Log weak signal warnings + const int rssi = WiFi.RSSI(); + if (rssi < -75) { + Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); + } + } + } + + // Handle web server requests - maximize throughput with watchdog safety if (webServer && webServer->isRunning()) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; @@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() { timeSinceLastHandleClient); } - // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data - // in chunks and each handleClient() call processes incoming data - constexpr int HANDLE_CLIENT_ITERATIONS = 10; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + // Reset watchdog BEFORE processing - HTTP header parsing can be slow + esp_task_wdt_reset(); + + // Process HTTP requests in tight loop for maximum throughput + // More iterations = more data processed per main loop cycle + constexpr int MAX_ITERATIONS = 500; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); + // Reset watchdog every 32 iterations + if ((i & 0x1F) == 0x1F) { + esp_task_wdt_reset(); + } + // Yield and check for exit button every 64 iterations + if ((i & 0x3F) == 0x3F) { + yield(); + // Check for exit button inside loop for responsiveness + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + } } lastHandleClientTime = millis(); } - // Handle exit on Back button + // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index a8653f43..07d44418 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -37,6 +37,14 @@ void WifiSelectionActivity::onEnter() { savePromptSelection = 0; forgetPromptSelection = 0; + // Cache MAC address for display + uint8_t mac[6]; + WiFi.macAddress(mac); + char macStr[32]; + snprintf(macStr, sizeof(macStr), "MAC address: %02x-%02x-%02x-%02x-%02x-%02x", mac[0], mac[1], mac[2], mac[3], mac[4], + mac[5]); + cachedMacAddress = std::string(macStr); + // Trigger first update to show scanning message updateRequired = true; @@ -572,6 +580,9 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr); } + // Show MAC address above the network count and legend + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 105, cachedMacAddress.c_str()); + // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 33ea26b1..0a7e7166 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -62,6 +62,9 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { // Password to potentially save (from keyboard or saved credentials) std::string enteredPassword; + // Cached MAC address string for display + std::string cachedMacAddress; + // Whether network was connected using a saved password (skip save prompt) bool usedSavedPassword = false; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1233a821..2eeba80f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -152,6 +152,8 @@ void EpubReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { @@ -166,7 +168,7 @@ void EpubReaderActivity::loop() { return; } - const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs; + const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; if (skipChapter) { // We don't want to delete the section mid-render, so grab the semaphore @@ -417,6 +419,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in 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; + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; // Position status bar near the bottom of the logical screen, regardless of orientation const auto screenHeight = renderer.getScreenHeight(); @@ -437,7 +441,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); } if (showChapterTitle) { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 63f1e5a7..8f3ecb80 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -16,7 +16,9 @@ int EpubReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; // Ensure we always have at least one item per page to avoid division by zero @@ -134,5 +136,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() { tocIndex != selectorIndex); } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 33c2c3e4..3ef42c1c 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -52,7 +52,7 @@ void FileSelectionActivity::loadFiles() { } else { auto filename = std::string(name); if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || - StringUtils::checkFileExtension(filename, ".xtc")) { + StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index cb123e1c..c00f6236 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -3,6 +3,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "Txt.h" +#include "TxtReaderActivity.h" #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" @@ -20,6 +22,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) { return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch"); } +bool ReaderActivity::isTxtFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + return ext4 == ".txt" || ext4 == ".TXT"; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SdMan.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -50,6 +58,21 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } +std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); + return nullptr; + } + + auto txt = std::unique_ptr(new Txt(path, "/.crosspoint")); + if (txt->load()) { + return txt; + } + + Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); + return nullptr; +} + void ReaderActivity::onSelectBookFile(const std::string& path) { currentBookPath = path; // Track current book path exitActivity(); @@ -67,6 +90,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) { delay(2000); onGoToFileSelection(); } + } else if (isTxtFile(path)) { + // Load TXT file + auto txt = loadTxt(path); + if (txt) { + onGoToTxtReader(std::move(txt)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT", + EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { // Load EPUB file auto epub = loadEpub(path); @@ -108,6 +143,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { [this] { onGoBack(); })); } +void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + exitActivity(); + enterNewActivity(new TxtReaderActivity( + renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); @@ -125,6 +169,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isTxtFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToTxtReader(std::move(txt)); } else { auto epub = loadEpub(initialBookPath); if (!epub) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index df44afe5..bec2a45b 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -5,6 +5,7 @@ class Epub; class Xtc; +class Txt; class ReaderActivity final : public ActivityWithSubactivity { std::string initialBookPath; @@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); + static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); + static bool isTxtFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void onSelectBookFile(const std::string& path); void onGoToFileSelection(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); + void onGoToTxtReader(std::unique_ptr txt); public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp new file mode 100644 index 00000000..db725320 --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -0,0 +1,700 @@ +#include "TxtReaderActivity.h" + +#include +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr unsigned long goHomeMs = 1000; +constexpr int statusBarMargin = 25; +constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading + +// Cache file magic and version +constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" +constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes +} // namespace + +void TxtReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void TxtReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + if (!txt) { + 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(); + + txt->setupCacheDir(); + + // Save current txt as last opened file + APP_STATE.openEpubPath = txt->getPath(); + APP_STATE.saveToFile(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask", + 6144, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void TxtReaderActivity::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 + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + pageOffsets.clear(); + currentPageLines.clear(); + txt.reset(); +} + +void TxtReaderActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Long press BACK (1s+) goes directly to home + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + onGoBack(); + return; + } + + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + if (!prevReleased && !nextReleased) { + return; + } + + if (prevReleased && currentPage > 0) { + currentPage--; + updateRequired = true; + } else if (nextReleased && currentPage < totalPages - 1) { + currentPage++; + updateRequired = true; + } +} + +void TxtReaderActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void TxtReaderActivity::initializeReader() { + if (initialized) { + return; + } + + // Store current settings for cache validation + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.screenMargin; + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + + // Calculate viewport dimensions + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + const int lineHeight = renderer.getLineHeight(cachedFontId); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, + linesPerPage); + + // Try to load cached page index first + if (!loadPageIndexCache()) { + // Cache not found, build page index + buildPageIndex(); + // Save to cache for next time + savePageIndexCache(); + } + + // Load saved progress + loadProgress(); + + initialized = true; +} + +void TxtReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageOffsets.push_back(0); // First page starts at offset 0 + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + int lastProgressPercent = -1; + + Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); + + // Progress bar dimensions (matching EpubReaderActivity style) + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); + const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + const int barX = boxX + (boxWidth - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + + // Draw initial progress box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + + if (!loadPageAtOffset(offset, tempLines, nextOffset)) { + break; + } + + if (nextOffset <= offset) { + // No progress made, avoid infinite loop + break; + } + + offset = nextOffset; + if (offset < fileSize) { + pageOffsets.push_back(offset); + } + + // Update progress bar every 10% (matching EpubReaderActivity logic) + int progressPercent = (offset * 100) / fileSize; + if (lastProgressPercent / 10 != progressPercent / 10) { + lastProgressPercent = progressPercent; + + // Fill progress bar + const int fillWidth = (barWidth - 2) * progressPercent / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + + // Yield to other tasks periodically + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); +} + +bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { + outLines.clear(); + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + return false; + } + + // Read a chunk from file + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) { + Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + // Parse lines from buffer + size_t pos = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + if (!lineComplete && static_cast(outLines.size()) > 0) { + // Incomplete line and we already have some lines, stop here + break; + } + + // Calculate the actual length of line content in the buffer (excluding newline) + size_t lineContentLen = lineEnd - pos; + + // Check for carriage return + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + // Extract line content for display (without CR/LF) + std::string line(reinterpret_cast(buffer + pos), displayLen); + + // Track position within this source line (in bytes from pos) + size_t lineBytePos = 0; + + // Word wrap if needed + while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { + int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + + if (lineWidth <= viewportWidth) { + outLines.push_back(line); + lineBytePos = displayLen; // Consumed entire display content + line.clear(); + break; + } + + // Find break point + size_t breakPos = line.length(); + while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { + // Try to break at space + size_t spacePos = line.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + // Break at character boundary for UTF-8 + breakPos--; + // Make sure we don't break in the middle of a UTF-8 sequence + while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + breakPos = 1; + } + + outLines.push_back(line.substr(0, breakPos)); + + // Skip space at break point + size_t skipChars = breakPos; + if (breakPos < line.length() && line[breakPos] == ' ') { + skipChars++; + } + lineBytePos += skipChars; + line = line.substr(skipChars); + } + + // Determine how much of the source buffer we consumed + if (line.empty()) { + // Fully consumed this source line, move past the newline + pos = lineEnd + 1; + } else { + // Partially consumed - page is full mid-line + // Move pos to where we stopped in the line (NOT past the line) + pos = pos + lineBytePos; + break; + } + } + + // Ensure we make progress even if calculations go wrong + if (pos == 0 && !outLines.empty()) { + // Fallback: at minimum, consume something to avoid infinite loop + pos = 1; + } + + nextOffset = offset + pos; + + // Make sure we don't go past the file + if (nextOffset > fileSize) { + nextOffset = fileSize; + } + + free(buffer); + + return !outLines.empty(); +} + +void TxtReaderActivity::renderScreen() { + if (!txt) { + return; + } + + // Initialize reader if not done + if (!initialized) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + // Bounds check + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page content + size_t offset = pageOffsets[currentPage]; + size_t nextOffset; + currentPageLines.clear(); + loadPageAtOffset(offset, currentPageLines, nextOffset); + + renderer.clearScreen(); + renderPage(); + + // Save progress + saveProgress(); +} + +void TxtReaderActivity::renderPage() { + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + const int lineHeight = renderer.getLineHeight(cachedFontId); + const int contentWidth = viewportWidth; + + // Render text lines with alignment + auto renderLines = [&]() { + int y = orientedMarginTop; + for (const auto& line : currentPageLines) { + if (!line.empty()) { + int x = orientedMarginLeft; + + // Apply text alignment + switch (cachedParagraphAlignment) { + case CrossPointSettings::LEFT_ALIGN: + default: + // x already set to left margin + break; + case CrossPointSettings::CENTER_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + (contentWidth - textWidth) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + contentWidth - textWidth; + break; + } + case CrossPointSettings::JUSTIFIED: + // For plain text, justified is treated as left-aligned + // (true justification would require word spacing adjustments) + break; + } + + renderer.drawText(cachedFontId, x, y, line.c_str()); + } + y += lineHeight; + } + }; + + // First pass: BW rendering + renderLines(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } + + // Grayscale rendering pass (for anti-aliased fonts) + if (SETTINGS.textAntiAliasing) { + // Save BW buffer for restoration after grayscale pass + renderer.storeBwBuffer(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderLines(); + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderLines(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + + // Restore BW buffer + renderer.restoreBwBuffer(); + } +} + +void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) const { + 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 showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + + const auto screenHeight = renderer.getScreenHeight(); + const auto textY = screenHeight - orientedMarginBottom - 4; + int progressTextWidth = 0; + + if (showProgress) { + const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0; + const std::string progressStr = + std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%"; + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str()); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progressStr.c_str()); + } + + if (showBattery) { + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + } + + if (showTitle) { + const int titleMarginLeft = 50 + 30 + orientedMarginLeft; + const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; + const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; + + std::string title = txt->getTitle(); + int 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()); + } +} + +void TxtReaderActivity::saveProgress() const { + FsFile f; + if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + f.close(); + } +} + +void TxtReaderActivity::loadProgress() { + FsFile f; + if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); + } + f.close(); + } +} + +bool TxtReaderActivity::loadPageIndexCache() { + // Cache file format (using serialization module): + // - uint32_t: magic "TXTI" + // - uint8_t: cache version + // - uint32_t: file size (to validate cache) + // - int32_t: viewport width + // - int32_t: lines per page + // - int32_t: font ID (to invalidate cache on font change) + // - int32_t: screen margin (to invalidate cache on margin change) + // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - uint32_t: total pages count + // - N * uint32_t: page offsets + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForRead("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); + return false; + } + + // Read and validate header using serialization module + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); + f.close(); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); + f.close(); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + // Read page offsets + pageOffsets.clear(); + pageOffsets.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t offset; + serialization::readPod(f, offset); + pageOffsets.push_back(offset); + } + + f.close(); + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); + return true; +} + +void TxtReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForWrite("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); + return; + } + + // Write header using serialization module + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + // Write page offsets + for (size_t offset : pageOffsets) { + serialization::writePod(f, static_cast(offset)); + } + + f.close(); + Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h new file mode 100644 index 00000000..41ccbfbb --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "activities/ActivityWithSubactivity.h" + +class TxtReaderActivity final : public ActivityWithSubactivity { + std::unique_ptr txt; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onGoHome; + + // Streaming text reader - stores file offsets for each page + std::vector pageOffsets; // File offset for start of each page + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + // Cached settings for cache validation (different fonts/margins require re-indexing) + int cachedFontId = 0; + int cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + public: + explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt, + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("TxtReader", renderer, mappedInput), + txt(std::move(txt)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c0580cf6..a211e61c 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -112,6 +112,8 @@ void XtcReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { @@ -125,7 +127,7 @@ void XtcReaderActivity::loop() { return; } - const bool skipPages = mappedInput.getHeldTime() > skipPageMs; + const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs; const int skipAmount = skipPages ? 10 : 1; if (prevReleased) { diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index fd732924..b2cfecaa 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -14,7 +14,9 @@ int XtcReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; if (items < 1) { items = 1; @@ -147,5 +149,8 @@ void XtcReaderChapterSelectionActivity::renderScreen() { renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7dd491d2..c6ae9eba 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -16,22 +16,24 @@ // Define the static settings list namespace { -constexpr int settingsCount = 19; +constexpr int settingsCount = 21; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Action("Select Sleep BMP"), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), - SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), + SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), diff --git a/src/main.cpp b/src/main.cpp index 5261df3d..8a7c3b91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,18 +43,19 @@ GfxRenderer renderer(einkDisplay); Activity* currentActivity; // Fonts -EpdFont bookerly12RegularFont(&bookerly_12_regular); -EpdFont bookerly12BoldFont(&bookerly_12_bold); -EpdFont bookerly12ItalicFont(&bookerly_12_italic); -EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); -EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, - &bookerly12BoldItalicFont); EpdFont bookerly14RegularFont(&bookerly_14_regular); EpdFont bookerly14BoldFont(&bookerly_14_bold); EpdFont bookerly14ItalicFont(&bookerly_14_italic); EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic); EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont, &bookerly14BoldItalicFont); +#ifndef OMIT_FONTS +EpdFont bookerly12RegularFont(&bookerly_12_regular); +EpdFont bookerly12BoldFont(&bookerly_12_bold); +EpdFont bookerly12ItalicFont(&bookerly_12_italic); +EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); +EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, + &bookerly12BoldItalicFont); EpdFont bookerly16RegularFont(&bookerly_16_regular); EpdFont bookerly16BoldFont(&bookerly_16_bold); EpdFont bookerly16ItalicFont(&bookerly_16_italic); @@ -117,6 +118,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic); EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic); EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont, &opendyslexic14BoldItalicFont); +#endif // OMIT_FONTS EpdFont smallFont(¬osans_8_regular); EpdFontFamily smallFontFamily(&smallFont); @@ -239,10 +241,12 @@ void onGoHome() { void setupDisplayAndFonts() { einkDisplay.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); - renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); +#ifndef OMIT_FONTS + renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily); renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily); + renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); @@ -251,6 +255,7 @@ void setupDisplayAndFonts() { renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily); renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily); renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily); +#endif // OMIT_FONTS renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily); @@ -303,6 +308,7 @@ void setup() { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; + APP_STATE.lastSleepImage = 0; APP_STATE.saveToFile(); onGoToReader(path); } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8703c2ae..23ba36ba 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -15,6 +16,18 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); + +// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) +CrossPointWebServer* wsInstance = nullptr; + +// WebSocket upload state +FsFile wsUploadFile; +String wsUploadFileName; +String wsUploadPath; +size_t wsUploadSize = 0; +size_t wsUploadReceived = 0; +unsigned long wsUploadStartTime = 0; +bool wsUploadInProgress = false; } // namespace // File listing page template - now using generated headers: @@ -86,12 +99,22 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); + + // Start WebSocket server for fast binary uploads + Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); + wsServer.reset(new WebSocketsServer(wsPort)); + wsInstance = const_cast(this); + wsServer->begin(); + wsServer->onEvent(wsEventCallback); + Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); // Show the correct IP based on network mode const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); + Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -107,16 +130,29 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); - // Add delay to allow any in-flight handleClient() calls to complete - delay(100); - Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis()); + // Close any in-progress WebSocket upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + wsUploadInProgress = false; + } + + // Stop WebSocket server + if (wsServer) { + Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); + wsServer->close(); + wsServer.reset(); + wsInstance = nullptr; + Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); + } + + // Brief delay to allow any in-flight handleClient() calls to complete + delay(20); server->stop(); Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); - // Add another delay before deletion to ensure server->stop() completes - delay(50); - Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); + // Brief delay before deletion + delay(10); server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); @@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const { } server->handleClient(); + + // Handle WebSocket events + if (wsServer) { + wsServer->loop(); + } } void CrossPointWebServer::handleRoot() const { @@ -229,7 +270,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function 0 && uploadFile) { + esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write + const unsigned long writeStart = millis(); + const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos); + totalWriteTime += millis() - writeStart; + writeCount++; + esp_task_wdt_reset(); // Reset watchdog after SD write + + if (written != uploadBufferPos) { + Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos, + written); + uploadBufferPos = 0; + return false; + } + uploadBufferPos = 0; + } + return true; +} + void CrossPointWebServer::handleUpload() const { - static unsigned long lastWriteTime = 0; - static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; + // Reset watchdog at start of every upload callback - HTTP parsing can be slow + esp_task_wdt_reset(); + // Safety check: ensure server is still valid if (!running || !server) { Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); @@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const { const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { + // Reset watchdog - this is the critical 1% crash point + esp_task_wdt_reset(); + uploadFileName = upload.filename; uploadSize = 0; uploadSuccess = false; uploadError = ""; uploadStartTime = millis(); - lastWriteTime = millis(); lastLoggedSize = 0; + uploadBufferPos = 0; + totalWriteTime = 0; + writeCount = 0; // Get upload path from query parameter (defaults to root if not specified) // Note: We use query parameter instead of form data because multipart form @@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const { if (!filePath.endsWith("/")) filePath += "/"; filePath += uploadFileName; - // Check if file already exists + // Check if file already exists - SD operations can be slow + esp_task_wdt_reset(); if (SdMan.exists(filePath.c_str())) { Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); + esp_task_wdt_reset(); SdMan.remove(filePath.c_str()); } - // Open file for writing + // Open file for writing - this can be slow due to FAT cluster allocation + esp_task_wdt_reset(); if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; } + esp_task_wdt_reset(); Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - const unsigned long writeStartTime = millis(); - const size_t written = uploadFile.write(upload.buf, upload.currentSize); - const unsigned long writeEndTime = millis(); - const unsigned long writeDuration = writeEndTime - writeStartTime; + // Buffer incoming data and flush when buffer is full + // This reduces SD card write operations and improves throughput + const uint8_t* data = upload.buf; + size_t remaining = upload.currentSize; - if (written != upload.currentSize) { - uploadError = "Failed to write to SD card - disk may be full"; - uploadFile.close(); - Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize, - written); - } else { - uploadSize += written; + while (remaining > 0) { + const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos; + const size_t toCopy = (remaining < space) ? remaining : space; - // Log progress every 50KB or if write took >100ms - if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - const unsigned long timeSinceStart = millis() - uploadStartTime; - const unsigned long timeSinceLastWrite = millis() - lastWriteTime; - const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + memcpy(uploadBuffer + uploadBufferPos, data, toCopy); + uploadBufferPos += toCopy; + data += toCopy; + remaining -= toCopy; - Serial.printf( - "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " - "ms\n", - millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite); - lastLoggedSize = uploadSize; + // Flush buffer when full + if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) { + if (!flushUploadBuffer()) { + uploadError = "Failed to write to SD card - disk may be full"; + uploadFile.close(); + return; + } } - lastWriteTime = millis(); + } + + uploadSize += upload.currentSize; + + // Log progress every 100KB + if (uploadSize - lastLoggedSize >= 102400) { + const unsigned long elapsed = millis() - uploadStartTime; + const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize, + uploadSize / 1024.0, kbps, writeCount); + lastLoggedSize = uploadSize; } } } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { + // Flush any remaining buffered data + if (!flushUploadBuffer()) { + uploadError = "Failed to write final data to SD card"; + } uploadFile.close(); if (uploadError.isEmpty()) { uploadSuccess = true; - Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); + const unsigned long elapsed = millis() - uploadStartTime; + const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), + uploadFileName.c_str(), uploadSize, elapsed, avgKbps); + Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), + writeCount, totalWriteTime, writePercent); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { + uploadBufferPos = 0; // Discard buffered data if (uploadFile) { uploadFile.close(); // Try to delete the incomplete file @@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +// WebSocket callback trampoline +void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (wsInstance) { + wsInstance->onWebSocketEvent(num, type, payload, length); + } +} + +// WebSocket event handler for fast binary uploads +// Protocol: +// 1. Client sends TEXT message: "START:::" +// 2. Client sends BINARY messages with file data chunks +// 3. Server sends TEXT "PROGRESS::" after each chunk +// 4. Server sends TEXT "DONE" or "ERROR:" when complete +void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + switch (type) { + case WStype_DISCONNECTED: + Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); + // Clean up any in-progress upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + // Delete incomplete file + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); + } + wsUploadInProgress = false; + break; + + case WStype_CONNECTED: { + Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); + break; + } + + case WStype_TEXT: { + // Parse control messages + String msg = String((char*)payload); + Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); + + if (msg.startsWith("START:")) { + // Parse: START::: + int firstColon = msg.indexOf(':', 6); + int secondColon = msg.indexOf(':', firstColon + 1); + + if (firstColon > 0 && secondColon > 0) { + wsUploadFileName = msg.substring(6, firstColon); + wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); + wsUploadPath = msg.substring(secondColon + 1); + wsUploadReceived = 0; + wsUploadStartTime = millis(); + + // Ensure path is valid + if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath; + if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) { + wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); + } + + // Build file path + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + + Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), + wsUploadSize, filePath.c_str()); + + // Check if file exists and remove it + esp_task_wdt_reset(); + if (SdMan.exists(filePath.c_str())) { + SdMan.remove(filePath.c_str()); + } + + // Open file for writing + esp_task_wdt_reset(); + if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { + wsServer->sendTXT(num, "ERROR:Failed to create file"); + wsUploadInProgress = false; + return; + } + esp_task_wdt_reset(); + + wsUploadInProgress = true; + wsServer->sendTXT(num, "READY"); + } else { + wsServer->sendTXT(num, "ERROR:Invalid START format"); + } + } + break; + } + + case WStype_BIN: { + if (!wsUploadInProgress || !wsUploadFile) { + wsServer->sendTXT(num, "ERROR:No upload in progress"); + return; + } + + // Write binary data directly to file + esp_task_wdt_reset(); + size_t written = wsUploadFile.write(payload, length); + esp_task_wdt_reset(); + + if (written != length) { + wsUploadFile.close(); + wsUploadInProgress = false; + wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); + return; + } + + wsUploadReceived += written; + + // Send progress update (every 64KB or at end) + static size_t lastProgressSent = 0; + if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { + String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); + wsServer->sendTXT(num, progress); + lastProgressSent = wsUploadReceived; + } + + // Check if upload complete + if (wsUploadReceived >= wsUploadSize) { + wsUploadFile.close(); + wsUploadInProgress = false; + + unsigned long elapsed = millis() - wsUploadStartTime; + float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; + + Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), + wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + + wsServer->sendTXT(num, "DONE"); + lastProgressSent = 0; + } + break; + } + + default: + break; + } +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4a..ecc2d3d2 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include @@ -34,9 +35,15 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; + std::unique_ptr wsServer = nullptr; bool running = false; bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; + uint16_t wsPort = 81; // WebSocket port + + // WebSocket upload state + void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); + static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); // File scanning void scanFiles(const char* path, const std::function& callback) const; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 017c6870..c4de3a05 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -2,13 +2,23 @@ #include #include +#include #include #include +#include "util/UrlUtils.h" + bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); @@ -33,8 +43,15 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, ProgressCallback progress) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 4afd9de4..d831af0a 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest"; } OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 08c0a0be..bfdbe3cc 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -816,6 +816,151 @@ } let failedUploadsGlobal = []; +let wsConnection = null; +const WS_PORT = 81; +const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability + +// Get WebSocket URL based on current page location +function getWsUrl() { + const host = window.location.hostname; + return `ws://${host}:${WS_PORT}/`; +} + +// Upload file via WebSocket (faster, binary protocol) +function uploadFileWebSocket(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(getWsUrl()); + let uploadStarted = false; + let sendingChunks = false; + + ws.binaryType = 'arraybuffer'; + + ws.onopen = function() { + console.log('[WS] Connected, starting upload:', file.name); + // Send start message: START::: + ws.send(`START:${file.name}:${file.size}:${currentPath}`); + }; + + ws.onmessage = async function(event) { + const msg = event.data; + console.log('[WS] Message:', msg); + + if (msg === 'READY') { + uploadStarted = true; + sendingChunks = true; + + // Small delay to let connection stabilize + await new Promise(r => setTimeout(r, 50)); + + try { + // Send file in chunks + const totalSize = file.size; + let offset = 0; + + while (offset < totalSize && ws.readyState === WebSocket.OPEN) { + const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); + const chunk = file.slice(offset, offset + chunkSize); + const buffer = await chunk.arrayBuffer(); + + // Wait for buffer to clear - more aggressive backpressure + while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { + await new Promise(r => setTimeout(r, 5)); + } + + if (ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket closed during upload'); + } + + ws.send(buffer); + offset += chunkSize; + + // Update local progress - cap at 95% since server still needs to write + // Final 100% shown when server confirms DONE + if (onProgress) { + const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); + onProgress(cappedOffset, totalSize); + } + } + + sendingChunks = false; + console.log('[WS] All chunks sent, waiting for DONE'); + } catch (err) { + console.error('[WS] Error sending chunks:', err); + sendingChunks = false; + ws.close(); + reject(err); + } + } else if (msg.startsWith('PROGRESS:')) { + // Server confirmed progress - log for debugging but don't update UI + // (local progress is smoother, server progress causes jumping) + console.log('[WS] Server progress:', msg); + } else if (msg === 'DONE') { + // Show 100% when server confirms completion + if (onProgress) onProgress(file.size, file.size); + ws.close(); + if (onComplete) onComplete(); + resolve(); + } else if (msg.startsWith('ERROR:')) { + const error = msg.substring(6); + ws.close(); + if (onError) onError(error); + reject(new Error(error)); + } + }; + + ws.onerror = function(event) { + console.error('[WS] Error:', event); + if (!uploadStarted) { + reject(new Error('WebSocket connection failed')); + } else if (!sendingChunks) { + reject(new Error('WebSocket error during upload')); + } + }; + + ws.onclose = function(event) { + console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); + if (sendingChunks) { + reject(new Error('WebSocket closed unexpectedly')); + } + }; + }); +} + +// Upload file via HTTP (fallback method) +function uploadFileHTTP(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) { + onProgress(e.loaded, e.total); + } + }; + + xhr.onload = function() { + if (xhr.status === 200) { + if (onComplete) onComplete(); + resolve(); + } else { + const error = xhr.responseText || 'Upload failed'; + if (onError) onError(error); + reject(new Error(error)); + } + }; + + xhr.onerror = function() { + const error = 'Network error'; + if (onError) onError(error); + reject(new Error(error)); + }; + + xhr.send(formData); + }); +} function uploadFile() { const fileInput = document.getElementById('fileInput'); @@ -836,8 +981,9 @@ function uploadFile() { let currentIndex = 0; const failedFiles = []; + let useWebSocket = true; // Try WebSocket first - function uploadNextFile() { + async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { @@ -845,67 +991,71 @@ function uploadFile() { progressText.textContent = 'All uploads complete!'; setTimeout(() => { closeUploadModal(); - hydrate(); // Refresh file list instead of reloading + hydrate(); }, 1000); } else { progressFill.style.backgroundColor = '#e74c3c'; const failedList = failedFiles.map(f => f.name).join(', '); progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; - - // Store failed files globally and show banner failedUploadsGlobal = failedFiles; - setTimeout(() => { closeUploadModal(); showFailedUploadsBanner(); - hydrate(); // Refresh file list to show successfully uploaded files + hydrate(); }, 2000); } return; } const file = files[currentIndex]; - const formData = new FormData(); - formData.append('file', file); - - const xhr = new XMLHttpRequest(); - // Include path as query parameter since multipart form data doesn't make - // form fields available until after file upload completes - xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); - progressFill.style.width = '0%'; - progressFill.style.backgroundColor = '#4caf50'; - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; + progressFill.style.backgroundColor = '#27ae60'; + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; - xhr.upload.onprogress = function (e) { - if (e.lengthComputable) { - const percent = Math.round((e.loaded / e.total) * 100); - progressFill.style.width = percent + '%'; - progressText.textContent = - `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; - } + const onProgress = (loaded, total) => { + const percent = Math.round((loaded / total) * 100); + progressFill.style.width = percent + '%'; + const speed = ''; // Could calculate speed here + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; }; - xhr.onload = function () { - if (xhr.status === 200) { - currentIndex++; - uploadNextFile(); // upload next file - } else { - // Track failure and continue with next file - failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); - currentIndex++; - uploadNextFile(); - } - }; - - xhr.onerror = function () { - // Track network error and continue with next file - failedFiles.push({ name: file.name, error: 'network error', file: file }); + const onComplete = () => { currentIndex++; uploadNextFile(); }; - xhr.send(formData); + const onError = (error) => { + failedFiles.push({ name: file.name, error: error, file: file }); + currentIndex++; + uploadNextFile(); + }; + + try { + if (useWebSocket) { + await uploadFileWebSocket(file, onProgress, null, null); + onComplete(); + } else { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } + } catch (error) { + console.error('Upload error:', error); + if (useWebSocket && error.message === 'WebSocket connection failed') { + // Fall back to HTTP for all subsequent uploads + console.log('WebSocket failed, falling back to HTTP'); + useWebSocket = false; + // Retry this file with HTTP + try { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } catch (httpError) { + onError(httpError.message); + } + } else { + onError(error.message); + } + } } uploadNextFile(); diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index e296d378..e56bc9df 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -49,4 +49,23 @@ bool checkFileExtension(const std::string& fileName, const char* extension) { return true; } +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + // Walk back to find the start of the last UTF-8 character + // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) + while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { + --pos; + } + str.resize(pos); + return pos; +} + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, const size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index f846cf17..e001d7b3 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -16,4 +16,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); */ bool checkFileExtension(const std::string& fileName, const char* extension); +// UTF-8 safe string truncation - removes one character from the end +// Returns the new size after removing one UTF-8 character +size_t utf8RemoveLastChar(std::string& str); + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, size_t numChars); } // namespace StringUtils diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp index 0eeeae3a..bbf5ac15 100644 --- a/src/util/UrlUtils.cpp +++ b/src/util/UrlUtils.cpp @@ -2,6 +2,8 @@ namespace UrlUtils { +bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; } + std::string ensureProtocol(const std::string& url) { if (url.find("://") == std::string::npos) { return "http://" + url; diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h index d88ca13c..6428161b 100644 --- a/src/util/UrlUtils.h +++ b/src/util/UrlUtils.h @@ -3,6 +3,11 @@ namespace UrlUtils { +/** + * Check if URL uses HTTPS protocol + */ +bool isHttpsUrl(const std::string& url); + /** * Prepend http:// if no protocol specified (server will redirect to https if needed) */