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: