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
10 changed files with 667 additions and 64 deletions

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;