From 6fbdd061019a418ea662fd79cd82b5302f3e07f6 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Thu, 1 Jan 2026 19:02:05 +0900 Subject: [PATCH 1/4] feat(home): Add cover image thumbnail to Continue Reading card Display book cover image as background in the Continue Reading card on the home screen, improving visual identification of the current book. Key changes: - Add thumbnail generation (thumb.bmp) for EPUB and XTC/XTCH files - Uses same dithering/scaling algorithms as sleep screen covers - Target size: 240x400 (half screen) for optimal Continue Reading card fit - Add JpegToBmpConverter::jpegFileToBmpStreamWithSize() for custom target sizes - Add GfxRenderer::copyStoredBwBuffer() and freeStoredBwBuffer() for framebuffer caching to maintain fast navigation performance - Add UTF-8 safe string truncation for Korean/CJK text in title/author display - Draw white boxes behind title/author text for readability over cover image - Increase HomeActivityTask stack size to 4096 for cover image rendering - Add bounds checking in XTC thumbnail generation to prevent buffer overflow --- lib/Epub/Epub.cpp | 63 ++++ lib/Epub/Epub.h | 2 + lib/GfxRenderer/GfxRenderer.cpp | 72 ++++- lib/GfxRenderer/GfxRenderer.h | 6 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 30 +- lib/JpegToBmpConverter/JpegToBmpConverter.h | 4 +- lib/Xtc/Xtc.cpp | 264 ++++++++++++++++ lib/Xtc/Xtc.h | 3 + src/activities/home/HomeActivity.cpp | 281 +++++++++++++++--- src/activities/home/HomeActivity.h | 6 +- 10 files changed, 667 insertions(+), 64 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index fde2e16a..92d2e2c5 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -328,6 +328,69 @@ bool Epub::generateCoverBmp() const { return false; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Epub::generateThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); + return false; + } + + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + + if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + FsFile thumbBmp; + if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { + coverJpg.close(); + return false; + } + // Use smaller target size for Continue Reading card (half of screen: 240x400) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + const bool success = + JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); + coverJpg.close(); + thumbBmp.close(); + SdMan.remove(coverJpgTempPath.c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), + success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); + } + + return false; +} + uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { if (itemHref.empty()) { Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 1b82462d..7b5eb7c4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -43,6 +43,8 @@ class Epub { const std::string& getAuthor() const; std::string getCoverBmpPath() const; bool generateCoverBmp() const; + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 638fdf01..7718892f 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -181,13 +181,14 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. - int screenY = y + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); - if (isScaled) { - screenY = std::floor(screenY * scale); - } + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); if (screenY >= getScreenHeight()) { break; } + if (screenY < 0) { + continue; + } if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); @@ -197,13 +198,13 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con } for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { - int screenX = x + bmpX; - if (isScaled) { - screenX = std::floor(screenX * scale); - } + int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); if (screenX >= getScreenWidth()) { break; } + if (screenX < 0) { + continue; + } const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; @@ -435,6 +436,61 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } +bool GfxRenderer::copyStoredBwBuffer() { + // Check if all chunks are allocated + for (const auto& bwBufferChunk : bwBufferChunks) { + if (!bwBufferChunk) { + return false; + } + } + + uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { + const size_t offset = i * BW_BUFFER_CHUNK_SIZE; + memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); + } + + return true; +} + +void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); } + +/** + * Copy stored BW buffer to framebuffer without freeing the stored chunks. + * Use this when you want to restore the buffer but keep it for later reuse. + * Returns true if buffer was copied successfully. + */ +bool GfxRenderer::copyStoredBwBuffer() { + // Check if all chunks are allocated + for (const auto& bwBufferChunk : bwBufferChunks) { + if (!bwBufferChunk) { + return false; + } + } + + uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { + const size_t offset = i * BW_BUFFER_CHUNK_SIZE; + memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); + } + + return true; +} + +/** + * Free the stored BW buffer chunks manually. + * Use this when you no longer need the stored buffer. + */ +void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); } + /** * Cleanup grayscale buffers using the current frame buffer. * Use this when BW buffer was re-rendered instead of stored/restored. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 7b0bcc00..8fc28e2f 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -88,8 +88,10 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer + bool copyStoredBwBuffer(); // Copy stored buffer to framebuffer without freeing + void freeStoredBwBuffer(); // Free the stored buffer manually void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 9c61ef0d..445d88a5 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -356,7 +356,7 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { } // Helper function: Write BMP header with 2-bit color depth -void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { +static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up const int imageSize = bytesPerRow * height; @@ -427,9 +427,10 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Core function: Convert JPEG file to 2-bit BMP -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); +// Internal implementation with configurable target size +bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, + int targetHeight) { + Serial.printf("[%lu] [JPG] Converting JPEG to BMP (target: %dx%d)\n", millis(), targetWidth, targetHeight); // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -464,10 +465,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) { // Calculate scale to fit within target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; outWidth = static_cast(imageInfo.m_width * scale); @@ -484,7 +485,7 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { needsScaling = true; Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, - imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); } // Write BMP header with output dimensions @@ -493,7 +494,7 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; } else { - writeBmpHeader(bmpOut, outWidth, outHeight); + writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } @@ -736,3 +737,14 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); return true; } + +// Core function: Convert JPEG file to 2-bit BMP (uses default target size) +bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); +} + +// Convert with custom target size (for thumbnails) +bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight); +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index f61bd8ef..b5af0785 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,11 +5,13 @@ class Print; class ZipFile; class JpegToBmpConverter { - static void writeBmpHeader(Print& bmpOut, int width, int height); // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); + static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight); public: static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + // Convert with custom target size (for thumbnails) + static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); }; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 8f79c9dd..68745676 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -293,6 +293,270 @@ bool Xtc::generateCoverBmp() const { return true; } +std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Xtc::generateThumbBmp() const { + // Already generated + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); + return false; + } + + if (parser->getPageCount() == 0) { + Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get first page info for cover + xtc::PageInfo pageInfo; + if (!parser->getPageInfo(0, pageInfo)) { + Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); + return false; + } + + // Get bit depth + const uint8_t bitDepth = parser->getBitDepth(); + + // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + + // Calculate scale factor + float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; + float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + // Only scale down, never up + if (scale >= 1.0f) { + // Page is already small enough, just use cover.bmp + // Copy cover.bmp to thumb.bmp + if (generateCoverBmp()) { + FsFile src, dst; + if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { + uint8_t buffer[512]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + dst.close(); + } + src.close(); + } + Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); + return SdMan.exists(getThumbBmpPath().c_str()); + } + return false; + } + + uint16_t thumbWidth = static_cast(pageInfo.width * scale); + uint16_t thumbHeight = static_cast(pageInfo.height * scale); + + Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, + pageInfo.height, thumbWidth, thumbHeight, scale); + + // Allocate buffer for page data + size_t bitmapSize; + if (bitDepth == 2) { + bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + } + uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); + if (!pageBuffer) { + Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); + return false; + } + + // Load first page (cover) + size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); + if (bytesRead == 0) { + Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); + free(pageBuffer); + return false; + } + + // Create thumbnail BMP file - use 2-bit format like EPUB covers + FsFile thumbBmp; + if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { + Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write 2-bit BMP header (same format as JpegToBmpConverter) + const uint32_t rowSize = (thumbWidth * 2 + 31) / 32 * 4; // 2 bits per pixel, aligned + const uint32_t imageSize = rowSize * thumbHeight; + const uint32_t fileSize = 14 + 40 + 16 + imageSize; // 16 bytes for 4-color palette + + // File header + thumbBmp.write('B'); + thumbBmp.write('M'); + thumbBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + thumbBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 16; // 2-bit palette has 4 colors (16 bytes) + thumbBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header + uint32_t dibHeaderSize = 40; + thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t widthVal = thumbWidth; + thumbBmp.write(reinterpret_cast(&widthVal), 4); + int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down + thumbBmp.write(reinterpret_cast(&heightVal), 4); + uint16_t planes = 1; + thumbBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 2; // 2-bit for 4 grayscale levels + thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; + thumbBmp.write(reinterpret_cast(&compression), 4); + thumbBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; + thumbBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + thumbBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 4; + thumbBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 4; + thumbBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette (4 colors for 2-bit, same as JpegToBmpConverter) + uint8_t palette[16] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) + 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) + 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + }; + thumbBmp.write(palette, 16); + + // Allocate row buffer for 2-bit output + const size_t dstRowSize = (thumbWidth * 2 + 7) / 8; + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + free(pageBuffer); + thumbBmp.close(); + return false; + } + + // Fixed-point scale factor (16.16) + uint32_t scaleInv_fp = static_cast(65536.0f / scale); + + // Pre-calculate plane info for 2-bit mode + const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; + const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; + const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; + const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; + const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; + + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuffer, 0xFF, rowSize); // Start with all white (color 3) + + // Calculate source Y range with bounds checking + uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; + uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; + if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + // Calculate source X range with bounds checking + uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; + uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; + if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + + // Area averaging: sum grayscale values (0-255 range) + uint32_t graySum = 0; + uint32_t totalCount = 0; + + for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { + for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { + uint8_t grayValue = 255; // Default: white + + if (bitDepth == 2) { + // XTH 2-bit mode: pixel value 0-3 + // Bounds check for column index + if (srcX < pageInfo.width) { + const size_t colIndex = pageInfo.width - 1 - srcX; + const size_t byteInCol = srcY / 8; + const size_t bitInByte = 7 - (srcY % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + // Bounds check for buffer access + if (byteOffset < planeSize) { + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + const uint8_t pixelValue = (bit1 << 1) | bit2; + // Convert 2-bit (0-3) to grayscale: 0=black, 3=white + // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) + grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 + } + } + } else { + // 1-bit mode + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + const size_t bitIdx = 7 - (srcX % 8); + // Bounds check for buffer access + if (byteIdx < bitmapSize) { + const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; + // XTC polarity: 1=black, 0=white + grayValue = pixelBit ? 0 : 255; + } + } + + graySum += grayValue; + totalCount++; + } + } + + // Calculate average grayscale and quantize to 2-bit + uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; + + // Quantize to 4 levels (same thresholds as JpegToBmpConverter) + uint8_t twoBit; + if (avgGray < 43) { + twoBit = 0; // Black + } else if (avgGray < 128) { + twoBit = 1; // Dark gray + } else if (avgGray < 213) { + twoBit = 2; // Light gray + } else { + twoBit = 3; // White + } + + // Pack 2-bit value into row buffer (MSB first) + const size_t byteIndex = (dstX * 2) / 8; + const size_t bitOffset = 6 - ((dstX * 2) % 8); + // Bounds check for row buffer access + if (byteIndex < rowSize) { + rowBuffer[byteIndex] &= ~(0x03 << bitOffset); // Clear bits + rowBuffer[byteIndex] |= (twoBit << bitOffset); // Set bits + } + } + + // Write row (already padded to 4-byte boundary by rowSize) + thumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + thumbBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, + getThumbBmpPath().c_str()); + return true; +} + uint32_t Xtc::getPageCount() const { if (!loaded || !parser) { return 0; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index e5bce102..7413ef47 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -62,6 +62,9 @@ class Xtc { // Cover image support (for sleep screen) std::string getCoverBmpPath() const; bool generateCoverBmp() const; + // Thumbnail support (for Continue Reading card) + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; // Page access uint32_t getPageCount() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 11107fdc..23fb197e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,14 +1,51 @@ #include "HomeActivity.h" +#include #include #include #include +#include #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" #include "fontIds.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; +} + +// UTF-8 safe string truncation - removes one character from the end +// Returns the new size after removing one UTF-8 character +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + // Walk back to find the start of the last UTF-8 character + // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) + while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { + --pos; + } + str.resize(pos); + return pos; +} + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} +} // namespace + void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -34,7 +71,7 @@ void HomeActivity::onEnter() { const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : ""; const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : ""; - // If epub, try to load the metadata for title/author + // If epub, try to load the metadata for title/author and cover if (ext5 == ".epub") { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); @@ -44,10 +81,30 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (ext5 == ".xtch") { - lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (ext4 == ".xtc") { - lastBookTitle.resize(lastBookTitle.length() - 4); + // Try to generate thumbnail image for Continue Reading card + if (epub.generateThumbBmp()) { + coverBmpPath = epub.getThumbBmpPath(); + hasCoverImage = true; + } + } else if (ext5 == ".xtch" || ext4 == ".xtc") { + // Handle XTC file + Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) { + lastBookTitle = std::string(xtc.getTitle()); + } + // Try to generate thumbnail image for Continue Reading card + if (xtc.generateThumbBmp()) { + coverBmpPath = xtc.getThumbBmpPath(); + hasCoverImage = true; + } + } + // Remove extension from title if we don't have metadata + if (lastBookTitle.length() >= 5 && ext5 == ".xtch") { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (lastBookTitle.length() >= 4 && ext4 == ".xtc") { + lastBookTitle.resize(lastBookTitle.length() - 4); + } } } @@ -57,7 +114,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 2048, // Stack size + 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -75,6 +132,12 @@ void HomeActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + // Free the stored cover buffer if any + if (coverBufferStored) { + renderer.freeStoredBwBuffer(); + coverBufferStored = false; + } } void HomeActivity::loop() { @@ -128,8 +191,12 @@ void HomeActivity::displayTaskLoop() { } } -void HomeActivity::render() const { - renderer.clearScreen(); +void HomeActivity::render() { + // If we have a stored cover buffer, restore it instead of clearing + const bool bufferRestored = coverBufferStored && renderer.copyStoredBwBuffer(); + if (!bufferRestored) { + renderer.clearScreen(); + } const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -146,32 +213,92 @@ void HomeActivity::render() const { // Draw book card regardless, fill with message based on `hasContinueReading` { - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { + // First time: load cover from SD and store buffer + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate position to center image within the book card + int coverX, coverY; + + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { + const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + + if (imgRatio > boxRatio) { + coverX = bookX; + coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; + } else { + coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; + coverY = bookY; + } + } else { + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; + } + + // Draw the cover image centered within the book card + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + + // Draw border around the card + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + + coverRendered = true; + // Store the buffer with cover image for fast navigation + coverBufferStored = renderer.storeBwBuffer(); + } + file.close(); + } + } else if (!bufferRestored && !coverRendered) { + // No cover image: draw border or fill + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + } } - // Bookmark icon in the top-right corner of the card - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; - constexpr int bookmarkY = bookY + 1; - - // Main bookmark body (solid) - renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); - - // Carve out an inverted triangle notch at the bottom center to create angled points - const int notchHeight = bookmarkHeight / 2; // depth of the notch - for (int i = 0; i < notchHeight; ++i) { - const int y = bookmarkY + bookmarkHeight - 1 - i; - const int xStart = bookmarkX + i; - const int width = bookmarkWidth - 2 * i; - if (width <= 0) { - break; + // If buffer was restored, just draw selection border if needed + if (bufferRestored && bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } else if (!coverRendered) { + // No cover: draw border for non-cover case + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } + } + + // Bookmark icon in the top-right corner of the card (inside the box) + // Skip if buffer was restored (bookmark is already in the buffer) + if (!bufferRestored) { + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + + // Main bookmark body (solid) - white on cover, inverted on selection + const bool bookmarkWhite = coverRendered ? true : !bookSelected; + renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, bookmarkWhite); + + // Carve out an inverted triangle notch at the bottom center to create angled points + const int notchHeight = bookmarkHeight / 2; // depth of the notch + const bool notchColor = coverRendered ? false : bookSelected; + for (int i = 0; i < notchHeight; ++i) { + const int y = bookmarkY + bookmarkHeight - 1 - i; + const int xStart = bookmarkX + i; + const int width = bookmarkWidth - 2 * i; + if (width <= 0) { + break; + } + // Draw a horizontal strip in the opposite color to "cut" the notch + renderer.fillRect(xStart, y, width, 1, notchColor); } - // Draw a horizontal strip in the opposite color to "cut" the notch - renderer.fillRect(xStart, y, width, 1, bookSelected); } } @@ -208,18 +335,25 @@ void HomeActivity::render() const { lines.back().append("..."); while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - lines.back().resize(lines.back().size() - 5); + // Remove "..." first, then remove one UTF-8 char, then add "..." back + lines.back().resize(lines.back().size() - 3); // Remove "..." + utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; } int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && i.size() > 5) { - // Word itself is too long, trim it - i.resize(i.size() - 5); - i.append("..."); - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && !i.empty()) { + // Word itself is too long, trim it (UTF-8 safe) + utf8RemoveLastChar(i); + // Check if we have room for ellipsis + std::string withEllipsis = i + "..."; + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); + if (wordWidth <= maxLineWidth) { + i = withEllipsis; + break; + } } int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); @@ -251,24 +385,85 @@ void HomeActivity::render() const { // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + // If cover image was rendered, draw white box behind title and author + if (coverRendered) { + constexpr int boxPadding = 8; + // Calculate the max text width for the box + int maxTextWidth = 0; + for (const auto& line : lines) { + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + if (lineWidth > maxTextWidth) { + maxTextWidth = lineWidth; + } + } + if (!lastBookAuthor.empty()) { + std::string trimmedAuthor = lastBookAuthor; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + utf8RemoveLastChar(trimmedAuthor); + } + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { + trimmedAuthor.append("..."); + } + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (authorWidth > maxTextWidth) { + maxTextWidth = authorWidth; + } + } + + const int boxWidth = maxTextWidth + boxPadding * 2; + const int boxHeight = totalTextHeight + boxPadding * 2; + const int boxX = (pageWidth - boxWidth) / 2; + const int boxY = titleYStart - boxPadding; + + // Draw white filled box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + // Draw black border around the box + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, true); + } + for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected || coverRendered); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long + // Trim author if too long (UTF-8 safe) + bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - trimmedAuthor.resize(trimmedAuthor.size() - 5); + utf8RemoveLastChar(trimmedAuthor); + wasTrimmed = true; + } + if (wasTrimmed && !trimmedAuthor.empty()) { + // Make room for ellipsis + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && + !trimmedAuthor.empty()) { + utf8RemoveLastChar(trimmedAuthor); + } trimmedAuthor.append("..."); } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected || coverRendered); } - renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, - "Continue Reading", !bookSelected); + // "Continue Reading" label at the bottom + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + if (coverRendered) { + // Draw white box behind "Continue Reading" text + const char* continueText = "Continue Reading"; + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); + constexpr int continuePadding = 6; + const int continueBoxWidth = continueTextWidth + continuePadding * 2; + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; + const int continueBoxX = (pageWidth - continueBoxWidth) / 2; + const int continueBoxY = continueY - continuePadding / 2; + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, false); + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, true); + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, true); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); + } } else { // No book to continue reading const int y = diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index b6c9767d..a9c95233 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,8 +13,12 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + bool hasCoverImage = false; + bool coverRendered = false; // Track if cover has been rendered once + bool coverBufferStored = false; // Track if cover buffer is stored std::string lastBookTitle; std::string lastBookAuthor; + std::string coverBmpPath; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; @@ -22,7 +26,7 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); - void render() const; + void render(); int getMenuItemCount() const; public: From fbda7aa4f1f15986873543d5d58ecd97b94dd059 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Mon, 5 Jan 2026 00:47:42 +0900 Subject: [PATCH 2/4] feat(home): Improve Continue Reading cover with 1-bit Atkinson dithering - Add 1-bit BMP generation with Atkinson dithering for better quality thumbnails - Replace noise dithering with error diffusion for smoother gradients - Add fillPolygon() to GfxRenderer for proper bookmark ribbon shape - Change bookmark from rect+triangle carve to pentagon polygon - Fix bookmark inversion when Continue Reading card is selected - Show selection state on first render (not just after navigation) - Fix 1-bit BMP palette lookup in Bitmap::readRow() - Add drawBitmap1Bit() optimized path for 1-bit BMPs The 1-bit format eliminates gray passes on home screen for faster rendering while Atkinson dithering maintains good image quality through error diffusion. --- lib/Epub/Epub.cpp | 3 +- lib/GfxRenderer/Bitmap.cpp | 5 +- lib/GfxRenderer/Bitmap.h | 2 + lib/GfxRenderer/GfxRenderer.cpp | 164 ++++++++++++--- lib/GfxRenderer/GfxRenderer.h | 2 + lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 195 ++++++++++++++++-- lib/JpegToBmpConverter/JpegToBmpConverter.h | 6 +- lib/Xtc/Xtc.cpp | 65 +++--- src/activities/home/HomeActivity.cpp | 96 ++++++--- 9 files changed, 433 insertions(+), 105 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 92d2e2c5..60097773 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -369,10 +369,11 @@ bool Epub::generateThumbBmp() const { return false; } // Use smaller target size for Continue Reading card (half of screen: 240x400) + // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) constexpr int THUMB_TARGET_WIDTH = 240; constexpr int THUMB_TARGET_HEIGHT = 400; const bool success = - JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); + JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); coverJpg.close(); thumbBmp.close(); SdMan.remove(coverJpgTempPath.c_str()); diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 7c46df1c..1c4ca8ca 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -341,7 +341,10 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons } case 1: { for (int x = 0; x < width; x++) { - lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + // Get palette index (0 or 1) from bit at position x + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + // Use palette lookup for proper black/white mapping + lum = paletteLum[palIndex]; packPixel(lum); } break; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 7e799647..bbd72eef 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -38,6 +38,8 @@ class Bitmap { bool isTopDown() const { return topDown; } bool hasGreyscale() const { return bpp > 1; } int getRowBytes() const { return rowBytes; } + bool is1Bit() const { return bpp == 1; } + uint16_t getBpp() const { return bpp; } private: static uint16_t readLE16(FsFile& f); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 7718892f..27ca9e94 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -154,6 +154,12 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight) const { + // For 1-bit bitmaps, use optimized 1-bit rendering path + if (bitmap.is1Bit()) { + drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight); + return; + } + float scale = 1.0f; bool isScaled = false; if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { @@ -222,6 +228,141 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } +void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, + const int maxHeight) const { + float scale = 1.0f; + bool isScaled = false; + if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + // For 1-bit BMP, output is still 2-bit packed (for consistency with readRow) + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); + if (screenY >= getScreenHeight()) { + break; + } + if (screenY < 0) { + continue; + } + + if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { + Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); + if (screenX >= getScreenWidth()) { + break; + } + if (screenX < 0) { + continue; + } + + // Get 2-bit value (result of readRow quantization) + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) + // val < 3 means black pixel (draw it) + if (val < 3) { + drawPixel(screenX, screenY, true); + } + // White pixels (val == 3) are not drawn (leave background) + } + } + + free(outputRow); + free(rowBytes); +} + +void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { + if (numPoints < 3) return; + + // Find bounding box + int minY = yPoints[0], maxY = yPoints[0]; + for (int i = 1; i < numPoints; i++) { + if (yPoints[i] < minY) minY = yPoints[i]; + if (yPoints[i] > maxY) maxY = yPoints[i]; + } + + // Clip to screen + if (minY < 0) minY = 0; + if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1; + + // Allocate node buffer for scanline algorithm + auto* nodeX = static_cast(malloc(numPoints * sizeof(int))); + if (!nodeX) { + Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); + return; + } + + // Scanline fill algorithm + for (int scanY = minY; scanY <= maxY; scanY++) { + int nodes = 0; + + // Find all intersection points with edges + int j = numPoints - 1; + for (int i = 0; i < numPoints; i++) { + if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) { + // Calculate X intersection using fixed-point to avoid float + int dy = yPoints[j] - yPoints[i]; + if (dy != 0) { + nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy; + } + } + j = i; + } + + // Sort nodes by X (simple bubble sort, numPoints is small) + for (int i = 0; i < nodes - 1; i++) { + for (int k = i + 1; k < nodes; k++) { + if (nodeX[i] > nodeX[k]) { + int temp = nodeX[i]; + nodeX[i] = nodeX[k]; + nodeX[k] = temp; + } + } + } + + // Fill between pairs of nodes + for (int i = 0; i < nodes - 1; i += 2) { + int startX = nodeX[i]; + int endX = nodeX[i + 1]; + + // Clip to screen + if (startX < 0) startX = 0; + if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; + + // Draw horizontal line + for (int x = startX; x <= endX; x++) { + drawPixel(x, scanY, state); + } + } + } + + free(nodeX); +} + void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::invertScreen() const { @@ -436,29 +577,6 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } -bool GfxRenderer::copyStoredBwBuffer() { - // Check if all chunks are allocated - for (const auto& bwBufferChunk : bwBufferChunks) { - if (!bwBufferChunk) { - return false; - } - } - - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); - if (!frameBuffer) { - return false; - } - - for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { - const size_t offset = i * BW_BUFFER_CHUNK_SIZE; - memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); - } - - return true; -} - -void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); } - /** * Copy stored BW buffer to framebuffer without freeing the stored chunks. * Use this when you want to restore the buffer but keep it for later reuse. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 8fc28e2f..6500a263 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -67,6 +67,8 @@ class GfxRenderer { void fillRect(int x, int y, int width, int height, bool state = true) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 445d88a5..637e275d 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -114,6 +114,96 @@ static inline uint8_t quantize(int gray, int x, int y) { } } +// 1-bit noise dithering for fast home screen rendering +// Uses hash-based noise for consistent dithering that works well at small sizes +static inline uint8_t quantize1bit(int gray, int x, int y) { + gray = adjustPixel(gray); + + // Generate noise threshold using integer hash (no regular pattern to alias) + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + + // Simple threshold with noise: gray >= (128 + noise offset) -> white + // The noise adds variation around the 128 midpoint + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + return (gray >= adjustedThreshold) ? 1 : 0; +} + +// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails +// Error distribution pattern (same as 2-bit but quantizes to 2 levels): +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +class Atkinson1BitDitherer { + public: + Atkinson1BitDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~Atkinson1BitDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + uint8_t processPixel(int gray, int x) { + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 2 levels (1-bit): 0 = black, 1 = white + uint8_t quantized; + int quantizedValue; + if (adjusted < 128) { + quantized = 0; + quantizedValue = 0; + } else { + quantized = 1; + quantizedValue = 255; + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results // Error distribution pattern: // X 1/8 1/8 @@ -355,6 +445,45 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { } } +// Helper function: Write BMP header with 1-bit color depth (black and white) +static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8) + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 1); // Bits per pixel (1 bit) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 2); // colorsUsed + write32(bmpOut, 2); // colorsImportant + + // Color Palette (2 colors x 4 bytes = 8 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + // Note: In 1-bit BMP, palette index 0 = black, 1 = white + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + // Helper function: Write BMP header with 2-bit color depth static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) @@ -427,10 +556,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Internal implementation with configurable target size -bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, - int targetHeight) { - Serial.printf("[%lu] [JPG] Converting JPEG to BMP (target: %dx%d)\n", millis(), targetWidth, targetHeight); +// Internal implementation with configurable target size and bit depth +bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit) { + Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", + targetWidth, targetHeight); // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -490,9 +620,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // Write BMP header with output dimensions int bytesPerRow; - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; + } else if (oneBit) { + writeBmpHeader1bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel } else { writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; @@ -525,11 +658,16 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm return false; } - // Create ditherer if enabled (only for 2-bit output) + // Create ditherer if enabled // Use OUTPUT dimensions for dithering (after prescaling) AtkinsonDitherer* atkinsonDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr; - if (!USE_8BIT_OUTPUT) { + Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; + + if (oneBit) { + // For 1-bit output, use Atkinson dithering for better quality + atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(outWidth); } else if (USE_FLOYD_STEINBERG) { @@ -615,12 +753,24 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // No scaling - direct output (1:1 mapping) memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; uint8_t twoBit; @@ -678,12 +828,24 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, currentOutY); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; uint8_t twoBit; @@ -731,6 +893,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm if (fsDitherer) { delete fsDitherer; } + if (atkinson1BitDitherer) { + delete atkinson1BitDitherer; + } free(mcuRowBuffer); free(rowBuffer); @@ -740,11 +905,17 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // Core function: Convert JPEG file to 2-bit BMP (uses default target size) bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); } -// Convert with custom target size (for thumbnails) +// Convert with custom target size (for thumbnails, 2-bit) bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight); + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false); +} + +// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering +bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); } diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index b5af0785..d5e9b950 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,13 +5,15 @@ class Print; class ZipFile; class JpegToBmpConverter { - // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); - static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight); + static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit); public: static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); // Convert with custom target size (for thumbnails) static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); + // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering + static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); }; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 68745676..b0c5102d 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -383,7 +383,7 @@ bool Xtc::generateThumbBmp() const { return false; } - // Create thumbnail BMP file - use 2-bit format like EPUB covers + // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) FsFile thumbBmp; if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); @@ -391,10 +391,10 @@ bool Xtc::generateThumbBmp() const { return false; } - // Write 2-bit BMP header (same format as JpegToBmpConverter) - const uint32_t rowSize = (thumbWidth * 2 + 31) / 32 * 4; // 2 bits per pixel, aligned + // Write 1-bit BMP header for fast home screen rendering + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes const uint32_t imageSize = rowSize * thumbHeight; - const uint32_t fileSize = 14 + 40 + 16 + imageSize; // 16 bytes for 4-color palette + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette // File header thumbBmp.write('B'); @@ -402,7 +402,7 @@ bool Xtc::generateThumbBmp() const { thumbBmp.write(reinterpret_cast(&fileSize), 4); uint32_t reserved = 0; thumbBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 16; // 2-bit palette has 4 colors (16 bytes) + uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) thumbBmp.write(reinterpret_cast(&dataOffset), 4); // DIB header @@ -414,7 +414,7 @@ bool Xtc::generateThumbBmp() const { thumbBmp.write(reinterpret_cast(&heightVal), 4); uint16_t planes = 1; thumbBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 2; // 2-bit for 4 grayscale levels + uint16_t bitsPerPixel = 1; // 1-bit for black and white thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); uint32_t compression = 0; thumbBmp.write(reinterpret_cast(&compression), 4); @@ -423,22 +423,19 @@ bool Xtc::generateThumbBmp() const { thumbBmp.write(reinterpret_cast(&ppmX), 4); int32_t ppmY = 2835; thumbBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 4; + uint32_t colorsUsed = 2; thumbBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 4; + uint32_t colorsImportant = 2; thumbBmp.write(reinterpret_cast(&colorsImportant), 4); - // Color palette (4 colors for 2-bit, same as JpegToBmpConverter) - uint8_t palette[16] = { + // Color palette (2 colors for 1-bit: black and white) + uint8_t palette[8] = { 0x00, 0x00, 0x00, 0x00, // Color 0: Black - 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) - 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) - 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White }; - thumbBmp.write(palette, 16); + thumbBmp.write(palette, 8); - // Allocate row buffer for 2-bit output - const size_t dstRowSize = (thumbWidth * 2 + 7) / 8; + // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); @@ -457,7 +454,7 @@ bool Xtc::generateThumbBmp() const { const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuffer, 0xFF, rowSize); // Start with all white (color 3) + memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) // Calculate source Y range with bounds checking uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; @@ -519,28 +516,28 @@ bool Xtc::generateThumbBmp() const { } } - // Calculate average grayscale and quantize to 2-bit + // Calculate average grayscale and quantize to 1-bit with noise dithering uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; - // Quantize to 4 levels (same thresholds as JpegToBmpConverter) - uint8_t twoBit; - if (avgGray < 43) { - twoBit = 0; // Black - } else if (avgGray < 128) { - twoBit = 1; // Dark gray - } else if (avgGray < 213) { - twoBit = 2; // Light gray - } else { - twoBit = 3; // White - } + // Hash-based noise dithering for 1-bit output + uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 - // Pack 2-bit value into row buffer (MSB first) - const size_t byteIndex = (dstX * 2) / 8; - const size_t bitOffset = 6 - ((dstX * 2) % 8); + // Quantize to 1-bit: 0=black, 1=white + uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; + + // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) + const size_t byteIndex = dstX / 8; + const size_t bitOffset = 7 - (dstX % 8); // Bounds check for row buffer access if (byteIndex < rowSize) { - rowBuffer[byteIndex] &= ~(0x03 << bitOffset); // Clear bits - rowBuffer[byteIndex] |= (twoBit << bitOffset); // Set bits + if (oneBit) { + rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white + } else { + rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black + } } } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 23fb197e..e5d618e6 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -211,12 +211,18 @@ void HomeActivity::render() { constexpr int bookY = 30; const bool bookSelected = hasContinueReading && selectorIndex == 0; + // Bookmark dimensions (used in multiple places) + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + // Draw book card regardless, fill with message based on `hasContinueReading` { // Draw cover image as background if available (inside the box) // Only load from SD on first render, then use stored buffer if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { - // First time: load cover from SD and store buffer + // First time: load cover from SD and render FsFile file; if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); @@ -246,9 +252,39 @@ void HomeActivity::render() { // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - coverRendered = true; - // Store the buffer with cover image for fast navigation + // Draw bookmark ribbon immediately after cover + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (white normally, will be inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, false); + + // Store the buffer with cover image AND bookmark for fast navigation coverBufferStored = renderer.storeBwBuffer(); + coverRendered = true; + + // First render: if selected, draw selection indicators now + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + // Invert bookmark to black + renderer.fillPolygon(xPoints, yPoints, 5, true); + } } file.close(); } @@ -261,10 +297,33 @@ void HomeActivity::render() { } } - // If buffer was restored, just draw selection border if needed - if (bufferRestored && bookSelected) { + // If buffer was restored, draw selection indicators if needed + if (bufferRestored && bookSelected && coverRendered) { + // Draw selection border renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + + // Invert bookmark color when selected (draw black over the white bookmark) + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw black filled bookmark ribbon (inverted) + renderer.fillPolygon(xPoints, yPoints, 5, true); } else if (!coverRendered) { // No cover: draw border for non-cover case renderer.drawRect(bookX, bookY, bookWidth, bookHeight); @@ -273,33 +332,6 @@ void HomeActivity::render() { renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } } - - // Bookmark icon in the top-right corner of the card (inside the box) - // Skip if buffer was restored (bookmark is already in the buffer) - if (!bufferRestored) { - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; - const int bookmarkY = bookY + 5; - - // Main bookmark body (solid) - white on cover, inverted on selection - const bool bookmarkWhite = coverRendered ? true : !bookSelected; - renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, bookmarkWhite); - - // Carve out an inverted triangle notch at the bottom center to create angled points - const int notchHeight = bookmarkHeight / 2; // depth of the notch - const bool notchColor = coverRendered ? false : bookSelected; - for (int i = 0; i < notchHeight; ++i) { - const int y = bookmarkY + bookmarkHeight - 1 - i; - const int xStart = bookmarkX + i; - const int width = bookmarkWidth - 2 * i; - if (width <= 0) { - break; - } - // Draw a horizontal strip in the opposite color to "cut" the notch - renderer.fillRect(xStart, y, width, 1, notchColor); - } - } } if (hasContinueReading) { From 8fc51668d0912ea73e43b60a5df444cda718b7ec Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Mon, 5 Jan 2026 00:48:26 +0900 Subject: [PATCH 3/4] chore(clang-format-fix): fixing format --- lib/Epub/Epub.cpp | 4 +-- lib/GfxRenderer/GfxRenderer.cpp | 2 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 8 +++-- lib/Xtc/Xtc.cpp | 4 +-- src/activities/home/HomeActivity.cpp | 36 +++++++++---------- 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 60097773..aa6bb490 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -372,8 +372,8 @@ bool Epub::generateThumbBmp() const { // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) constexpr int THUMB_TARGET_WIDTH = 240; constexpr int THUMB_TARGET_HEIGHT = 400; - const bool success = - JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, + THUMB_TARGET_HEIGHT); coverJpg.close(); thumbBmp.close(); SdMan.remove(coverJpgTempPath.c_str()); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 27ca9e94..d2581b77 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -229,7 +229,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con } void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, - const int maxHeight) const { + const int maxHeight) const { float scale = 1.0f; bool isScaled = false; if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 637e275d..ebe69d0e 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -762,7 +762,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // 1-bit output with Atkinson dithering for better quality for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); + const uint8_t bit = + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); // Pack 1-bit value: MSB first, 8 pixels per byte const int byteIndex = x / 8; const int bitOffset = 7 - (x % 8); @@ -837,7 +838,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // 1-bit output with Atkinson dithering for better quality for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, currentOutY); + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, currentOutY); // Pack 1-bit value: MSB first, 8 pixels per byte const int byteIndex = x / 8; const int bitOffset = 7 - (x % 8); @@ -916,6 +918,6 @@ bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bm // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, - int targetMaxHeight) { + int targetMaxHeight) { return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); } diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index b0c5102d..7205ffb9 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -522,7 +522,7 @@ bool Xtc::generateThumbBmp() const { // Hash-based noise dithering for 1-bit output uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); // 0-255 + const int threshold = static_cast(hash >> 24); // 0-255 const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 // Quantize to 1-bit: 0=black, 1=white @@ -534,7 +534,7 @@ bool Xtc::generateThumbBmp() const { // Bounds check for row buffer access if (byteIndex < rowSize) { if (oneBit) { - rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white + rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white } else { rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index e5d618e6..98d87f1d 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -257,18 +257,18 @@ void HomeActivity::render() { const int centerX = bookmarkX + bookmarkWidth / 2; const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left }; const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left + bookmarkY + bookmarkHeight // bottom-left }; // Draw bookmark ribbon (white normally, will be inverted if selected) @@ -308,18 +308,18 @@ void HomeActivity::render() { const int centerX = bookmarkX + bookmarkWidth / 2; const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left }; const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left + bookmarkY + bookmarkHeight // bottom-left }; // Draw black filled bookmark ribbon (inverted) From f0fa90da0caef474bc971e18662bb9c1c19bfbac Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 9 Jan 2026 23:51:24 +0900 Subject: [PATCH 4/4] refactor(home): Address PR review feedback for Continue Reading cover - Invert bookmark icon logic: show bookmark only when no cover image (as visual decoration), hide when cover is displayed to show more art - Replace GfxRenderer::storeBwBuffer usage with HomeActivity's own buffer management (storeCoverBuffer/restoreCoverBuffer/freeCoverBuffer) - Remove copyStoredBwBuffer() and freeStoredBwBuffer() from GfxRenderer as they enabled misuse of the grayscale buffer storage mechanism --- lib/GfxRenderer/GfxRenderer.cpp | 32 ------- lib/GfxRenderer/GfxRenderer.h | 6 +- src/activities/home/HomeActivity.cpp | 134 +++++++++++++++------------ src/activities/home/HomeActivity.h | 4 + 4 files changed, 80 insertions(+), 96 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index d2581b77..b0e4c1f8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -577,38 +577,6 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } -/** - * Copy stored BW buffer to framebuffer without freeing the stored chunks. - * Use this when you want to restore the buffer but keep it for later reuse. - * Returns true if buffer was copied successfully. - */ -bool GfxRenderer::copyStoredBwBuffer() { - // Check if all chunks are allocated - for (const auto& bwBufferChunk : bwBufferChunks) { - if (!bwBufferChunk) { - return false; - } - } - - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); - if (!frameBuffer) { - return false; - } - - for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { - const size_t offset = i * BW_BUFFER_CHUNK_SIZE; - memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); - } - - return true; -} - -/** - * Free the stored BW buffer chunks manually. - * Use this when you no longer need the stored buffer. - */ -void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); } - /** * Cleanup grayscale buffers using the current frame buffer. * Use this when BW buffer was re-rendered instead of stored/restored. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 6500a263..97663ab9 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -90,10 +90,8 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); // Restore and free the stored buffer - bool copyStoredBwBuffer(); // Copy stored buffer to framebuffer without freeing - void freeStoredBwBuffer(); // Free the stored buffer manually + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 98d87f1d..27f9f74e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -134,10 +134,49 @@ void HomeActivity::onExit() { renderingMutex = nullptr; // Free the stored cover buffer if any - if (coverBufferStored) { - renderer.freeStoredBwBuffer(); - coverBufferStored = false; + freeCoverBuffer(); +} + +bool HomeActivity::storeCoverBuffer() { + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; } + + // Free any existing buffer first + freeCoverBuffer(); + + const size_t bufferSize = GfxRenderer::getBufferSize(); + coverBuffer = static_cast(malloc(bufferSize)); + if (!coverBuffer) { + return false; + } + + memcpy(coverBuffer, frameBuffer, bufferSize); + return true; +} + +bool HomeActivity::restoreCoverBuffer() { + if (!coverBuffer) { + return false; + } + + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + const size_t bufferSize = GfxRenderer::getBufferSize(); + memcpy(frameBuffer, coverBuffer, bufferSize); + return true; +} + +void HomeActivity::freeCoverBuffer() { + if (coverBuffer) { + free(coverBuffer); + coverBuffer = nullptr; + } + coverBufferStored = false; } void HomeActivity::loop() { @@ -193,7 +232,7 @@ void HomeActivity::displayTaskLoop() { void HomeActivity::render() { // If we have a stored cover buffer, restore it instead of clearing - const bool bufferRestored = coverBufferStored && renderer.copyStoredBwBuffer(); + const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); if (!bufferRestored) { renderer.clearScreen(); } @@ -252,85 +291,60 @@ void HomeActivity::render() { // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - // Draw bookmark ribbon immediately after cover - const int notchDepth = bookmarkHeight / 3; - const int centerX = bookmarkX + bookmarkWidth / 2; + // No bookmark ribbon when cover is shown - it would just cover the art - const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left - }; - const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right - bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left - }; - - // Draw bookmark ribbon (white normally, will be inverted if selected) - renderer.fillPolygon(xPoints, yPoints, 5, false); - - // Store the buffer with cover image AND bookmark for fast navigation - coverBufferStored = renderer.storeBwBuffer(); + // Store the buffer with cover image for fast navigation + coverBufferStored = storeCoverBuffer(); coverRendered = true; // First render: if selected, draw selection indicators now if (bookSelected) { renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - // Invert bookmark to black - renderer.fillPolygon(xPoints, yPoints, 5, true); } } file.close(); } } else if (!bufferRestored && !coverRendered) { - // No cover image: draw border or fill + // No cover image: draw border or fill, plus bookmark as visual flair if (bookSelected) { renderer.fillRect(bookX, bookY, bookWidth, bookHeight); } else { renderer.drawRect(bookX, bookY, bookWidth, bookHeight); } + + // Draw bookmark ribbon when no cover image (visual decoration) + if (hasContinueReading) { + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); + } } // If buffer was restored, draw selection indicators if needed if (bufferRestored && bookSelected && coverRendered) { - // Draw selection border + // Draw selection border (no bookmark inversion needed since cover has no bookmark) renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - - // Invert bookmark color when selected (draw black over the white bookmark) - const int notchDepth = bookmarkHeight / 3; - const int centerX = bookmarkX + bookmarkWidth / 2; - - const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left - }; - const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right - bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left - }; - - // Draw black filled bookmark ribbon (inverted) - renderer.fillPolygon(xPoints, yPoints, 5, true); - } else if (!coverRendered) { - // No cover: draw border for non-cover case - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - if (bookSelected) { - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - } + } else if (!coverRendered && !bufferRestored) { + // Selection border already handled above in the no-cover case } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index a9c95233..b35dd334 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -16,6 +16,7 @@ class HomeActivity final : public Activity { bool hasCoverImage = false; bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored + uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image std::string lastBookTitle; std::string lastBookAuthor; std::string coverBmpPath; @@ -28,6 +29,9 @@ class HomeActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render(); int getMenuItemCount() const; + bool storeCoverBuffer(); // Store frame buffer for cover image + bool restoreCoverBuffer(); // Restore frame buffer from stored cover + void freeCoverBuffer(); // Free the stored cover buffer public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,