diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index a36d11ad..12ec5046 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -457,9 +457,41 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm return false; } - const int effectiveSrcW = progressive ? (srcWidth + 7) / 8 : srcWidth; - const int effectiveSrcH = progressive ? (srcHeight + 7) / 8 : srcHeight; - const int decodeFlags = progressive ? JPEG_SCALE_EIGHTH : 0; + // Progressive JPEGs must stay at 1/8 (DC-only mode, only safe scale with MCU_SKIP patch). + // For baseline JPEGs with a target size, pick the largest built-in DCT scale that still + // keeps the decoded image >= the target — the custom scaler handles the fine remainder. + // This avoids decoding millions of pixels that are immediately thrown away. + int jpegDecodeFlags = 0; + int jpegScaleDenom = 1; + if (progressive) { + jpegDecodeFlags = JPEG_SCALE_EIGHTH; + jpegScaleDenom = 8; + } else if (targetWidth > 0 && targetHeight > 0) { + // Use max(scaleX, scaleY) as the constraint: the DCT pre-scale must keep BOTH axes + // >= target so the fine scaler always downscales (never upscales) on either axis. + // This is safe for both crop=true (which uses max scale) and crop=false (uses min scale). + const float scaleX = static_cast(targetWidth) / srcWidth; + const float scaleY = static_cast(targetHeight) / srcHeight; + const float scaleMax = scaleX > scaleY ? scaleX : scaleY; + if (scaleMax <= 0.125f) { + jpegDecodeFlags = JPEG_SCALE_EIGHTH; + jpegScaleDenom = 8; + } else if (scaleMax <= 0.25f) { + jpegDecodeFlags = JPEG_SCALE_QUARTER; + jpegScaleDenom = 4; + } else if (scaleMax <= 0.5f) { + jpegDecodeFlags = JPEG_SCALE_HALF; + jpegScaleDenom = 2; + } + } + + const int effectiveSrcW = (srcWidth + jpegScaleDenom - 1) / jpegScaleDenom; + const int effectiveSrcH = (srcHeight + jpegScaleDenom - 1) / jpegScaleDenom; + + if (jpegScaleDenom > 1) { + LOG_DBG("JPG", "Using JPEGDEC 1/%d DCT scale: %dx%d -> %dx%d", jpegScaleDenom, srcWidth, srcHeight, effectiveSrcW, + effectiveSrcH); + } // Calculate output dimensions (pre-scale to fit display exactly) int outWidth = effectiveSrcW; @@ -487,7 +519,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm scaleY_fp = (static_cast(effectiveSrcH) << 16) / outHeight; needsScaling = true; - LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", effectiveSrcW, effectiveSrcH, outWidth, outHeight, + LOG_DBG("JPG", "Fine-scaling %dx%d -> %dx%d (target %dx%d)", effectiveSrcW, effectiveSrcH, outWidth, outHeight, targetWidth, targetHeight); } @@ -570,7 +602,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); jpeg->setUserPointer(&ctx); - rc = jpeg->decode(0, 0, decodeFlags); + rc = jpeg->decode(0, 0, jpegDecodeFlags); if (rc != 1 || ctx.error) { LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError()); diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 787cf73b..99d324ff 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -1,5 +1,7 @@ #include "Txt.h" +#include +#include #include #include #include @@ -184,6 +186,98 @@ bool Txt::generateCoverBmp() const { return false; } +std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; } +std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } +std::string Txt::getThumbBmpPath(int width, int height) const { + return cachePath + "/thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp"; +} + +bool Txt::generateThumbBmp(int height) const { + const std::string destPath = getThumbBmpPath(height); + if (Storage.exists(destPath.c_str())) return true; + const int width = static_cast(height * 0.6f); + if (!generateThumbBmp(width, height)) return false; + const std::string srcPath = getThumbBmpPath(width, height); + Storage.rename(srcPath.c_str(), destPath.c_str()); + return Storage.exists(destPath.c_str()); +} + +bool Txt::generateThumbBmp(int width, int height) const { + const std::string thumbPath = getThumbBmpPath(width, height); + if (Storage.exists(thumbPath.c_str())) return true; + + setupCacheDir(); + + FsFile thumbBmp; + if (!Storage.openFileForWrite("TXT", thumbPath, thumbBmp)) return false; + + const uint32_t rowSize = (static_cast(width) + 31) / 32 * 4; + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, width, height, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(BmpHeader)); + + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + thumbBmp.close(); + Storage.remove(thumbPath.c_str()); + return false; + } + + // Matches the Lyra "no cover" placeholder: 1px border, white top third, black bottom two thirds. + // Book icon (32x32, 1=white/transparent, 0=black) centered in the white area. + // In 1-bit BMP: 1=white, 0=black. + static const uint8_t kIcon[] = { + 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFE, 0x1F, 0xE0, 0x00, 0x02, 0x1F, 0xE0, 0x00, 0x03, + 0x1F, 0xE0, 0x00, 0x03, 0x1F, 0xF0, 0x00, 0x01, 0x1F, 0xF0, 0x00, 0x01, 0x1F, 0xF0, 0x00, 0x01, 0x9F, 0xF8, 0x00, + 0x00, 0x9F, 0xF8, 0x00, 0x00, 0xDF, 0xFC, 0x00, 0x00, 0x6F, 0xFE, 0x00, 0x00, 0x3F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, + 0x80, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x0F, 0xFF, 0x00, 0x00, + 0x1F, 0xFE, 0x00, 0x00, 0x3F, 0xFC, 0x00, 0x00, 0x6F, 0xF8, 0x00, 0x00, 0xDF, 0xF8, 0x00, 0x00, 0x9F, 0xF0, 0x00, + 0x01, 0x9F, 0xF0, 0x00, 0x01, 0x1F, 0xE0, 0x00, 0x01, 0x1F, 0xE0, 0x00, 0x03, 0x1F, 0xE0, 0x00, 0x02, 0x1F, 0xE0, + 0x00, 0x02, 0x1F, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x1F}; + static constexpr int kIconSize = 32; + static constexpr int kIconStride = 4; // bytes per icon row (32 bits) + + const int splitY = height / 3; // white above, black below + const int iconX = (width - kIconSize) / 2; + const int iconY = (splitY - kIconSize) / 2; + + for (int y = 0; y < height; y++) { + const bool blackRegion = (y >= splitY); + memset(rowBuffer, blackRegion ? 0x00 : 0xFF, rowSize); + + // 1px border + if (y == 0 || y == height - 1) { + memset(rowBuffer, 0x00, rowSize); + } else { + // Left and right border pixels + rowBuffer[0] &= 0x7F; // clear MSB (x=0) + rowBuffer[(width - 1) / 8] &= ~(0x80u >> ((width - 1) % 8)); // clear x=width-1 + + // Overlay icon row if within icon bounds (icon is on white area) + const int iconRow = y - iconY; + if (!blackRegion && iconRow >= 0 && iconRow < kIconSize && iconX >= 0 && iconX + kIconSize <= width) { + // Icon format: 0=dark pixel, 1=white/transparent. In BMP: 0=black, 1=white. + // The icon pixels (0=dark) should clear bits in the white BMP region. + for (int ix = 0; ix < kIconSize; ix++) { + const int iconByte = iconRow * kIconStride + ix / 8; + const int iconBit = 7 - (ix % 8); + const bool iconDark = !((kIcon[iconByte] >> iconBit) & 1); + if (iconDark) { + const int bx = iconX + ix; + rowBuffer[bx / 8] &= ~(0x80u >> (bx % 8)); + } + } + } + } + thumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + thumbBmp.close(); + LOG_DBG("TXT", "Generated surrogate thumb BMP (%dx%d): %s", width, height, thumbPath.c_str()); + return true; +} + bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { if (!loaded) { return false; diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h index b342ca88..1c4e1e57 100644 --- a/lib/Txt/Txt.h +++ b/lib/Txt/Txt.h @@ -28,6 +28,13 @@ class Txt { [[nodiscard]] bool generateCoverBmp() const; [[nodiscard]] std::string findCoverImage() const; + // Thumbnail support - generates a surrogate placeholder cover BMP + [[nodiscard]] std::string getThumbBmpPath() const; + [[nodiscard]] std::string getThumbBmpPath(int height) const; + [[nodiscard]] std::string getThumbBmpPath(int width, int height) const; + [[nodiscard]] bool generateThumbBmp(int height) const; + [[nodiscard]] bool generateThumbBmp(int width, int height) const; + // Read content from file [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; }; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 2262b901..45111b29 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -239,6 +240,16 @@ void HomeActivity::loadRecentCovers(int coverHeight) { LOG_DBG("HOME", "Generated missing cover for %s", book.path.c_str()); } } + } else if (FsHelpers::hasTxtExtension(book.path) || FsHelpers::hasMarkdownExtension(book.path)) { + Txt txt(book.path, "/.crosspoint"); + const std::string thumbPath = txt.getThumbBmpPath(); + bool success = thumbSizes.empty() ? txt.generateThumbBmp(coverHeight) + : txt.generateThumbBmp(thumbSizes[0].first, thumbSizes[0].second); + if (success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, thumbPath); + book.coverBmpPath = thumbPath; + LOG_DBG("HOME", "Generated surrogate cover for %s", book.path.c_str()); + } } onCoverGenerated(); return; @@ -320,6 +331,12 @@ void HomeActivity::loadRecentCovers(int coverHeight) { if (!Storage.exists(path.c_str())) success = xtc.generateThumbBmp(sz.first, sz.second) && success; } } + } else if (FsHelpers::hasTxtExtension(book.path) || FsHelpers::hasMarkdownExtension(book.path)) { + Txt txt(book.path, "/.crosspoint"); + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) success = txt.generateThumbBmp(sz.first, sz.second) && success; + } } if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); @@ -352,6 +369,15 @@ void HomeActivity::loadRecentCovers(int coverHeight) { onCoverGenerated(); return; } + } else if (FsHelpers::hasTxtExtension(book.path) || FsHelpers::hasMarkdownExtension(book.path)) { + Txt txt(book.path, "/.crosspoint"); + bool success = txt.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } + onCoverGenerated(); + return; } } } diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 91bf9c86..e0cd6c04 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -54,7 +54,9 @@ void MdReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); + const std::string txtSidecar = ReaderActivity::sidecarCoverPath(filePath); + const std::string txtCover = txtSidecar.empty() ? txt->getThumbBmpPath() : txtSidecar; + RECENT_BOOKS.addBook(filePath, fileName, "", "", txtCover); requestUpdate(); } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index dbc2a912..fe2a1bb4 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -118,7 +118,9 @@ void TxtReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); + const std::string txtSidecar = ReaderActivity::sidecarCoverPath(filePath); + const std::string txtCover = txtSidecar.empty() ? txt->getThumbBmpPath() : txtSidecar; + RECENT_BOOKS.addBook(filePath, fileName, "", "", txtCover); // Trigger first update requestUpdate(); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 82214bc4..e70dedd8 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -516,7 +516,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("THEME", "Rendering bmp"); - // Draw the cover image (bookWidth and bookHeight already match image aspect ratio) + renderer.fillRect(bookX, bookY, bookWidth, bookHeight, false); renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight); // Draw border around the card diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index 7fa25e8f..1feaab84 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -57,6 +57,10 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con static_cast(tileWidth - 2 * hPaddingInSelection) / static_cast(coverHeight); const float cropX = std::max(0.0f, 1.0f - (tileRatio / ratio)); + // Clear tile to white before drawing: 1-bit BMPs only draw dark pixels, + // leaving white pixels transparent — any stale dark content shows through. + renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, coverHeight, false); renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection, coverHeight, cropX); } else { diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp index 603f7afd..acf2b4c1 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.cpp +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -338,6 +338,7 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const float bmpRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float tileRatio = static_cast(maxW) / static_cast(maxH); const float cropX = (bmpRatio > tileRatio) ? (1.0f - tileRatio / bmpRatio) : 0.0f; + renderer.fillRect(x, y, maxW, maxH, false); renderer.drawBitmap(bitmap, x, y, maxW, maxH, cropX, 0.0f); // Clear only the pixels outside the arc in each corner. // The arc centre for the top-left corner is (x+r, y+r). A pixel at diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 99b6c3ff..ad0244ab 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -568,6 +568,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { coverWidth = bitmap.getWidth(); + renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, coverHeight, false); renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, coverHeight); } else {