From 65d23910a3b813977dcedfc06871e3c3bff99a98 Mon Sep 17 00:00:00 2001 From: Andrew Brandt Date: Wed, 14 Jan 2026 04:04:02 -0600 Subject: [PATCH 01/15] ci: add PR format check workflow (#328) **Description**: Add a new workflow to check the PR formatting to ensure consistency on PR titles. We can also use this for semantic release versioning later, if we so desire. **Related Issue(s)**: Implements first portion of #327 --------- Signed-off-by: Andrew Brandt --- .github/workflows/pr-formatting-check.yml | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/pr-formatting-check.yml 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 }} From 2040e088e7b036e192c6ba52eb05f11e0f8406c3 Mon Sep 17 00:00:00 2001 From: Will Morrow Date: Wed, 14 Jan 2026 05:05:08 -0500 Subject: [PATCH 02/15] Ensure new custom sleep image every time (#300) When picking a random sleep image from a set of custom images, compare the randomly chosen index against a cached value in settings. If the value matches, use the next image (rolling over if it's the last image). Cache the chosen image index to settings in either case. ## Summary Implements a tweak on the custom sleep image feature that ensures that the user gets a new image every time the device goes to sleep. This change adds a new setting (perhaps there's a better place to cache this?) that stores the most recently used file index. During picking the random image index, we compare this against the random index and choose the next one (modulo the number of image files) if it matches, ensuring we get a new image. ## Additional Context As mentioned, I used settings to cache this value since it is a persisted store, perhaps that's overkill. Open to suggestions on if there's a better way. --- src/CrossPointState.cpp | 10 ++++++++-- src/CrossPointState.h | 1 + src/activities/boot_sleep/SleepActivity.cpp | 8 +++++++- src/main.cpp | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) 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/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 3305a16d..0d3eab0a 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -80,7 +80,13 @@ void SleepActivity::renderCustomSleepScreen() const { const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - const auto randomFileIndex = random(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)) { diff --git a/src/main.cpp b/src/main.cpp index 5261df3d..34e7376b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -303,6 +303,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); } From fecd1849b922e4389099c0fae551f846ad07beb2 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Wed, 14 Jan 2026 19:24:02 +0900 Subject: [PATCH 03/15] Add cover image display in *Continue Reading* card with framebuffer caching (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Display the book cover image in the **"Continue Reading"** card on the home screen, with fast navigation using framebuffer caching. * **What changes are included?** - Display book cover image in the "Continue Reading" card on home screen - Load cover from cached BMP (same as sleep screen cover) - Add framebuffer store/restore functions (`copyStoredBwBuffer`, `freeStoredBwBuffer`) for fast navigation after initial render - Fix `drawBitmap` scaling bug: apply scale to offset only, not to base coordinates - Add white text boxes behind title/author/continue reading label for readability on cover - Support both EPUB and XTC file cover images - Increase HomeActivity task stack size from 2048 to 4096 for cover image rendering ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). - Performance: First render loads cover from SD card (~800ms), subsequent navigation uses cached framebuffer (~instant) - Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home screen, freed on exit - Fallback: If cover image is not available, falls back to standard text-only display - The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale was incorrectly scaling the base coordinates. Now correctly uses screenY = y + (offset scale) --- lib/Epub/Epub.cpp | 64 ++++ lib/Epub/Epub.h | 2 + lib/GfxRenderer/Bitmap.cpp | 5 +- lib/GfxRenderer/Bitmap.h | 2 + lib/GfxRenderer/BitmapHelpers.cpp | 16 + lib/GfxRenderer/BitmapHelpers.h | 81 +++++ lib/GfxRenderer/GfxRenderer.cpp | 149 ++++++++ lib/GfxRenderer/GfxRenderer.h | 6 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 123 ++++++- lib/JpegToBmpConverter/JpegToBmpConverter.h | 8 +- lib/Xtc/Xtc.cpp | 261 ++++++++++++++ lib/Xtc/Xtc.h | 3 + src/activities/home/HomeActivity.cpp | 318 +++++++++++++++--- src/activities/home/HomeActivity.h | 10 +- 14 files changed, 984 insertions(+), 64 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 64727bca..1b337721 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -409,6 +409,70 @@ bool Epub::generateCoverBmp(bool cropped) 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 047c955a..91062aa4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -46,6 +46,8 @@ class Epub { const std::string& getAuthor() 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/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 1a3b4406..ad25ffcd 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -228,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; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 9ac7cfbb..544869c1 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -42,6 +42,8 @@ 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); diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index b0d9dc06..465593e8 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -88,3 +88,19 @@ uint8_t quantize(int gray, int x, int y) { 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 index 300527e0..791e70b9 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -5,8 +5,89 @@ // 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 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index cc1288a7..28022e90 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 { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e3e9558d..9d341bcc 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; @@ -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 30c1314f..01451a05 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -87,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; @@ -159,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}; @@ -196,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; @@ -218,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; } @@ -258,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) { @@ -348,12 +397,25 @@ 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 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; @@ -411,12 +473,25 @@ 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 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; @@ -464,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/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/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index dc031fde..1936d926 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,8 +1,10 @@ #include "HomeActivity.h" +#include #include #include #include +#include #include #include @@ -15,6 +17,29 @@ #include "fontIds.h" #include "util/StringUtils.h" +namespace { +// 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) { + 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, size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} +} // namespace + void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -46,7 +71,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); @@ -56,10 +81,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); + } } } @@ -69,7 +115,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 4096, // Stack size + 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -87,6 +133,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() { @@ -138,8 +229,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(); @@ -154,34 +249,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 } } @@ -218,18 +380,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 "..." + 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) + 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()); @@ -261,24 +430,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()) { + 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); + 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()) { + 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 = 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, From 14643d02253dde96d229fd85b39fd56ea2707aff Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 14 Jan 2026 21:19:12 +1100 Subject: [PATCH 04/15] Move string helpers out of HomeActivity into StringUtils --- src/activities/home/HomeActivity.cpp | 33 +++++----------------------- src/util/StringUtils.cpp | 19 ++++++++++++++++ src/util/StringUtils.h | 6 +++++ 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 1936d926..3a97e132 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -17,29 +17,6 @@ #include "fontIds.h" #include "util/StringUtils.h" -namespace { -// 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) { - 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, size_t numChars) { - for (size_t i = 0; i < numChars && !str.empty(); ++i) { - utf8RemoveLastChar(str); - } -} -} // namespace - void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -382,7 +359,7 @@ void HomeActivity::render() { while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { // Remove "..." first, then remove one UTF-8 char, then add "..." back lines.back().resize(lines.back().size() - 3); // Remove "..." - utf8RemoveLastChar(lines.back()); + StringUtils::utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; @@ -391,7 +368,7 @@ void HomeActivity::render() { int 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) - utf8RemoveLastChar(i); + StringUtils::utf8RemoveLastChar(i); // Check if we have room for ellipsis std::string withEllipsis = i + "..."; wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); @@ -444,7 +421,7 @@ void HomeActivity::render() { if (!lastBookAuthor.empty()) { std::string trimmedAuthor = lastBookAuthor; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); + StringUtils::utf8RemoveLastChar(trimmedAuthor); } if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { @@ -478,14 +455,14 @@ void HomeActivity::render() { // Trim author if too long (UTF-8 safe) bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); + 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()) { - utf8RemoveLastChar(trimmedAuthor); + StringUtils::utf8RemoveLastChar(trimmedAuthor); } trimmedAuthor.append("..."); } 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 From 49f97b69cabff5522d40bca2a4516a9bfec6b731 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Wed, 14 Jan 2026 19:36:40 +0900 Subject: [PATCH 05/15] Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work. --- lib/Txt/Txt.cpp | 191 +++++ lib/Txt/Txt.h | 33 + src/activities/boot_sleep/SleepActivity.cpp | 16 + .../reader/FileSelectionActivity.cpp | 2 +- src/activities/reader/ReaderActivity.cpp | 51 ++ src/activities/reader/ReaderActivity.h | 4 + src/activities/reader/TxtReaderActivity.cpp | 700 ++++++++++++++++++ src/activities/reader/TxtReaderActivity.h | 60 ++ 8 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 lib/Txt/Txt.cpp create mode 100644 lib/Txt/Txt.h create mode 100644 src/activities/reader/TxtReaderActivity.cpp create mode 100644 src/activities/reader/TxtReaderActivity.h 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/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 0d3eab0a..bf2b5857 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" @@ -207,6 +208,7 @@ 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 @@ -222,6 +224,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"); 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; +}; From 1c027ce2cd7370d5d02668dda9e956967e99a49f Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Wed, 14 Jan 2026 12:38:30 +0100 Subject: [PATCH 06/15] Skip BOM character (sometimes used in front of em-dashes) (#340) ## Summary Skip BOM character (sometimes used in front of em-dashes) - they are not part of the glyph set and would render `?` otherwise. --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b96d28f8..b9305b1e 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -151,6 +151,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'; From 9a9dc044ce86d738c33dbe58a83798bdd0a80a30 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Wed, 14 Jan 2026 12:40:40 +0100 Subject: [PATCH 07/15] ifdef around optional fonts to reduce flash size/time. (#339) ## Summary Adds define to omit optional fonts from the build. This reduces time to flash from >31s to <13s. Useful for development that doesn't require fonts. Addresses #193 Invoke it like this during development: `PLATFORMIO_BUILD_FLAGS="-D OMIT_FONTS" pio run --target upload && pio device monitor` Changing the define causes `pio` to do a full rebuild (but it will be quick if you keep the define). --- ### 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? NO --- src/main.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 34e7376b..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); From ed05554d74f4eae61a890a5304c87932a8deb046 Mon Sep 17 00:00:00 2001 From: Armando Cerna Date: Wed, 14 Jan 2026 06:47:24 -0500 Subject: [PATCH 08/15] feat: Add setting to toggle long-press chapter skip (#341) ## Summary Adds a new "Long-press Chapter Skip" toggle in Settings to control whether holding the side buttons skips chapters. I kept accidentally triggering chapter skips while reading, which caused me to lose my place in the middle of long chapters. ## Additional Context * 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? _**PARTIALLY **_ --- src/CrossPointSettings.cpp | 5 ++++- src/CrossPointSettings.h | 2 ++ src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 1ca9ea74..17b5d053 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 = 17; +constexpr uint8_t SETTINGS_COUNT = 18; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -47,6 +47,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); + serialization::writePod(outputFile, longPressChapterSkip); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -113,6 +114,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, longPressChapterSkip); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d5f91039..a5641aad 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -90,6 +90,8 @@ class CrossPointSettings { char opdsServerUrl[128] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; + // Long-press chapter skip on side buttons + uint8_t longPressChapterSkip = 1; ~CrossPointSettings() = default; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f51cf9bf..2eeba80f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -168,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 diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f22850a9..efa0b9e1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,7 +13,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 19; +constexpr int settingsCount = 20; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -29,6 +29,7 @@ const SettingInfo settingsList[settingsCount] = { {"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"}), From c2fb8ce55dc3c6d26b94c80880ecf5e4630d0a22 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:48:43 -0500 Subject: [PATCH 09/15] Update User Guide to reflect release 0.13.1 (#337) Please note I have not tested the Calibre features and am not yet in a position to offer detailed documentation of how they work. --- USER_GUIDE.md | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70a765ba..b411140e 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 @@ -63,18 +64,29 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con 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) - - "Blank" - A blank screen + - "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 @@ -83,16 +95,18 @@ 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. - **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 @@ -124,6 +138,8 @@ 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. From 847786e3426d043d6872ea9a254f732aa69a0c0e Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 06:54:14 -0500 Subject: [PATCH 10/15] Fixes issue with Calibre web expecting SSL (#347) http urls now work with Calibre web --------- Co-authored-by: Dave Allie --- src/network/HttpDownloader.cpp | 25 +++++++++++++++++++++---- src/util/UrlUtils.cpp | 2 ++ src/util/UrlUtils.h | 5 +++++ 3 files changed, 28 insertions(+), 4 deletions(-) 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/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) */ From a946c83a07d4b5e74ebc91e664945be46ee6f6cd Mon Sep 17 00:00:00 2001 From: swwilshub Date: Wed, 14 Jan 2026 12:11:28 +0000 Subject: [PATCH 11/15] Turbocharge WiFi uploads with WebSocket + watchdog stability (#364) ## Summary * **What is the goal of this PR?** Fix WiFi file transfer stability issues (especially crashes during uploads) and improve upload speed via WebSocket binary protocol. File transfers now don't really crash as much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s. * **What changes are included?** - **WebSocket upload support** - Adds WebSocket binary protocol for file uploads, achieving faster speeds 335 KB/s vs HTTP multipart) - **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls throughout upload path to prevent watchdog timeouts during: - File creation (FAT allocation can be slow) - SD card write operations - HTTP header parsing - WebSocket chunk processing - **4KB write buffering** - Batches SD card writes to reduce I/O overhead - **WiFi health monitoring** - Detects WiFi disconnection in STA mode and exits gracefully - **Improved handleClient loop** - 500 iterations with periodic watchdog resets and button checks for responsiveness - **Progress bar improvements** - Fixed jumping/inaccurate progress by capping local progress at 95% until server confirms completion - **Exit button responsiveness** - Button now checked inside the handleClient loop every 64 iterations - **Reduced exit delays** - Decreased shutdown delays from ~850ms to ~140ms **Files changed:** - `platformio.ini` - Added WebSockets library dependency - `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering, watchdog resets - `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop, button handling - `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback ## Additional Context - WebSocket uses 4KB chunks with backpressure management to prevent ESP32 buffer overflow - Falls back to HTTP automatically if WebSocket connection fails - The main bottleneck now is SD card write speed (~44% of transfer time), not WiFi - STA mode was more prone to crashes than AP mode due to external network factors; WiFi health monitoring helps detect and handle disconnections gracefully --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ Claude did it ALL, I have no idea what I am doing, but my books transfer fast now. --------- Co-authored-by: Claude --- platformio.ini | 1 + .../network/CrossPointWebServerActivity.cpp | 61 +++- src/network/CrossPointWebServer.cpp | 312 ++++++++++++++++-- src/network/CrossPointWebServer.h | 7 + src/network/html/FilesPage.html | 228 ++++++++++--- 5 files changed, 522 insertions(+), 87 deletions(-) diff --git a/platformio.ini b/platformio.ini index 703b9348..17ec6378 100644 --- a/platformio.ini +++ b/platformio.ini @@ -47,6 +47,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager ArduinoJson @ 7.4.2 QRCode @ 0.0.1 + links2004/WebSockets @ ^2.4.1 [env:default] extends = base 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/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/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(); From 3ee10b31ab25875cecab4b852af206aef54d98a7 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 14 Jan 2026 23:14:00 +1100 Subject: [PATCH 12/15] Update OTA updater URL --- src/network/OtaUpdater.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() { From 489220832f824f9ef62261c217bd28aedbd3cf7d Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:21:48 -0600 Subject: [PATCH 13/15] Only indent paragraphs for justify/left-align (#367) Currently, when Extra Paragraph Spacing is off, an em-space is added to the beginning of each ParsedText even for blocks like headers that are centered. This whitespace makes the centering slightly off. Change the calculation here to only add the em-space for left/justified text. Co-authored-by: Maeve Andrews --- lib/Epub/Epub/ParsedText.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 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"); } From e517945aaa3a15ba479ab283457127b1cdbf8775 Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:23:03 -0600 Subject: [PATCH 14/15] Add a bullet to the beginning of any
  • (#368) Currently there is no visual indication whatsoever if something is in a list. An `
  • ` is essentially just another paragraph. As a partial remedy for this, add a bullet character to the beginning of `
  • ` text blocks so that the user can see that they're list items. This is incomplete in that an `
      ` should also have a counter so that its list items can get numbers instead of bullets (right now I don't think we track if we're in a `
        ` or an `
          ` at all), but it's strictly better than the current situation. Co-authored-by: Maeve Andrews --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b9305b1e..e3e08310 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -97,6 +97,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); From 56ec3dfb6d95dcb7b523478ffc7eac30f3592ad5 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Thu, 15 Jan 2026 01:01:47 +1100 Subject: [PATCH 15/15] chore: Cut release 0.14.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 17ec6378..cbe47fe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.13.1 +version = 0.14.0 [base] platform = espressif32 @ 6.12.0