mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 07:07:38 +03:00
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:
parent
04ad4e5aa4
commit
6fbdd06101
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
264
lib/Xtc/Xtc.cpp
264
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<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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user