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
This commit is contained in:
Eunchurn Park 2026-01-01 19:02:05 +09:00
parent 04ad4e5aa4
commit 6fbdd06101
No known key found for this signature in database
GPG Key ID: 29D94D9C697E3F92
10 changed files with 667 additions and 64 deletions

View File

@ -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());

View File

@ -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;

View File

@ -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<int>(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<int>(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.

View File

@ -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

View File

@ -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<float>(TARGET_MAX_WIDTH) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(TARGET_MAX_HEIGHT) / imageInfo.m_height;
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
outWidth = static_cast<int>(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);
}

View File

@ -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);
};

View File

@ -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<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
float scaleY = static_cast<float>(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<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(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<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
}
uint8_t* pageBuffer = static_cast<uint8_t*>(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<xtc::XtcParser*>(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<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
uint32_t dataOffset = 14 + 40 + 16; // 2-bit palette has 4 colors (16 bytes)
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t widthVal = thumbWidth;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 2; // 2-bit for 4 grayscale levels
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 4;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 4;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&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<uint8_t*>(malloc(rowSize));
if (!rowBuffer) {
free(pageBuffer);
thumbBmp.close();
return false;
}
// Fixed-point scale factor (16.16)
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
// Pre-calculate plane info for 2-bit mode
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(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<uint32_t>(dstY) * scaleInv_fp) >> 16;
uint32_t srcYEnd = (static_cast<uint32_t>(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<uint32_t>(dstX) * scaleInv_fp) >> 16;
uint32_t srcXEnd = (static_cast<uint32_t>(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<uint8_t>(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;

View File

@ -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;

View File

@ -1,14 +1,51 @@
#include "HomeActivity.h"
#include <Bitmap.h>
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <Xtc.h>
#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<unsigned char>(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<HomeActivity*>(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<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(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 =

View File

@ -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<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> 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: