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