feat: restore book cover/thumbnail prerender on first open

- Add isValidThumbnailBmp(), generateInvalidFormatCoverBmp(), and
  generateInvalidFormatThumbBmp() methods to Epub class for validating
  BMP files and generating X-pattern marker images when cover extraction
  fails (e.g., progressive JPG).
- Restore prerender block in EpubReaderActivity::onEnter() that checks
  for missing cover BMPs (fit + cropped) and thumbnail BMPs at each
  PRERENDER_THUMB_HEIGHTS size, showing a "Preparing book..." popup
  with progress. Falls back to PlaceholderCoverGenerator, then to
  invalid-format marker BMPs as last resort.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-07 21:22:19 -05:00
parent 22c189281c
commit a5ca15df4f
3 changed files with 225 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
#include "Epub.h"
#include <FsHelpers.h>
#include <HalDisplay.h>
#include <HalStorage.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
@@ -706,6 +707,171 @@ bool Epub::generateThumbBmp(int height) const {
return false;
}
bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
if (!Storage.exists(bmpPath.c_str())) {
return false;
}
FsFile file = Storage.open(bmpPath.c_str());
if (!file) {
LOG_ERR("EBP", "Failed to open thumbnail BMP at path: %s", bmpPath.c_str());
return false;
}
size_t fileSize = file.size();
if (fileSize == 0) {
LOG_DBG("EBP", "Thumbnail BMP is empty (no cover marker) at path: %s", bmpPath.c_str());
file.close();
return false;
}
uint8_t header[2];
size_t bytesRead = file.read(header, 2);
if (bytesRead != 2) {
LOG_ERR("EBP", "Failed to read thumbnail BMP header at path: %s", bmpPath.c_str());
file.close();
return false;
}
file.close();
return header[0] == 'B' && header[1] == 'M';
}
bool Epub::generateInvalidFormatThumbBmp(int height) const {
const int width = height * 0.6;
const int rowBytes = ((width + 31) / 32) * 4;
const int imageSize = rowBytes * height;
const int fileSize = 14 + 40 + 8 + imageSize;
const int dataOffset = 14 + 40 + 8;
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
return false;
}
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);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t bmpWidth = width;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
int32_t bmpHeight = -height;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1;
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 = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
thumbBmp.write(black, 4);
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
thumbBmp.write(white, 4);
for (int y = 0; y < height; y++) {
std::vector<uint8_t> rowData(rowBytes, 0xFF);
const int scaledY = (y * width) / height;
const int thickness = 2;
for (int x = 0; x < width; x++) {
bool drawPixel = false;
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
if (drawPixel) {
const int byteIndex = x / 8;
const int bitIndex = 7 - (x % 8);
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
}
}
thumbBmp.write(rowData.data(), rowBytes);
}
thumbBmp.close();
LOG_DBG("EBP", "Generated invalid format thumbnail BMP");
return true;
}
bool Epub::generateInvalidFormatCoverBmp(bool cropped) const {
const int hwW = static_cast<int>(HalDisplay::DISPLAY_WIDTH);
const int hwH = static_cast<int>(HalDisplay::DISPLAY_HEIGHT);
const int width = std::min(hwW, hwH);
const int height = std::max(hwW, hwH);
const int rowBytes = ((width + 31) / 32) * 4;
const int imageSize = rowBytes * height;
const int fileSize = 14 + 40 + 8 + imageSize;
const int dataOffset = 14 + 40 + 8;
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
return false;
}
coverBmp.write('B');
coverBmp.write('M');
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
uint32_t dibHeaderSize = 40;
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t bmpWidth = width;
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpWidth), 4);
int32_t bmpHeight = -height;
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeight), 4);
uint16_t planes = 1;
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1;
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835;
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
coverBmp.write(black, 4);
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
coverBmp.write(white, 4);
for (int y = 0; y < height; y++) {
std::vector<uint8_t> rowData(rowBytes, 0xFF);
const int scaledY = (y * width) / height;
const int thickness = 6;
for (int x = 0; x < width; x++) {
bool drawPixel = false;
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
if (drawPixel) {
const int byteIndex = x / 8;
const int bitIndex = 7 - (x % 8);
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
}
}
coverBmp.write(rowData.data(), rowBytes);
}
coverBmp.close();
LOG_DBG("EBP", "Generated invalid format cover BMP");
return true;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) {
LOG_DBG("EBP", "Failed to read item, empty href");

View File

@@ -56,6 +56,9 @@ class Epub {
std::string getThumbBmpPath() const;
std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const;
bool generateInvalidFormatCoverBmp(bool cropped = false) const;
bool generateInvalidFormatThumbBmp(int height) const;
static bool isValidThumbnailBmp(const std::string& bmpPath);
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

@@ -3,6 +3,7 @@
#include <Epub/Page.h>
#include <Epub/blocks/TextBlock.h>
#include <FsHelpers.h>
#include <PlaceholderCoverGenerator.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -115,6 +116,61 @@ void EpubReaderActivity::onEnter() {
}
}
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
{
int totalSteps = 0;
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) totalSteps++;
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
}
if (totalSteps > 0) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
int completedSteps = 0;
auto updateProgress = [&]() {
completedSteps++;
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
};
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
epub->generateCoverBmp(false);
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(),
480, 800)) {
epub->generateInvalidFormatCoverBmp(false);
}
}
updateProgress();
}
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
epub->generateCoverBmp(true);
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(),
480, 800)) {
epub->generateInvalidFormatCoverBmp(true);
}
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
if (!PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
epub->getAuthor(), thumbWidth, thumbHeight)) {
epub->generateInvalidFormatThumbBmp(thumbHeight);
}
}
updateProgress();
}
}
}
}
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();