fix: Port upstream cover extraction fallback and outline improvements
Port PR #838 (epub cover fallback logic) and PR #907 (cover outlines): - Add fallback cover filename probing when EPUB metadata lacks cover info - Case-insensitive extension checking for cover images - Detect and re-generate corrupt/empty thumbnail BMPs - Always draw outline rect on cover tiles for legibility (PR #907) - Upgrade Storage.exists() checks to Epub::isValidThumbnailBmp() - Fallback chain: Real Cover → PlaceholderCoverGenerator → X-pattern marker - Add epub.load retry logic (cache-only first, then full build) - Adapt upstream Serial.printf calls to LOG_DBG/LOG_ERR macros Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <JpegToBmpConverter.h>
|
#include <JpegToBmpConverter.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
#include "Epub/parsers/ContentOpfParser.h"
|
#include "Epub/parsers/ContentOpfParser.h"
|
||||||
#include "Epub/parsers/TocNavParser.h"
|
#include "Epub/parsers/TocNavParser.h"
|
||||||
@@ -440,9 +443,18 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Epub::generateCoverBmp(bool cropped) const {
|
bool Epub::generateCoverBmp(bool cropped) const {
|
||||||
|
bool invalid = false;
|
||||||
// Already generated, return true
|
// Already generated, return true
|
||||||
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
||||||
return true;
|
// is this a valid cover or just an empty file we created to mark generation attempts?
|
||||||
|
invalid = !isValidThumbnailBmp(getCoverBmpPath(cropped));
|
||||||
|
if (invalid) {
|
||||||
|
// Remove the old invalid cover so we can attempt to generate a new one
|
||||||
|
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||||
|
LOG_DBG("EBP", "Previous cover generation attempt failed for %s mode, retrying", cropped ? "cropped" : "fit");
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||||
@@ -451,13 +463,33 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||||
|
std::string effectiveCoverImageHref = coverImageHref;
|
||||||
if (coverImageHref.empty()) {
|
if (coverImageHref.empty()) {
|
||||||
|
// Fallback: try common cover filenames
|
||||||
|
std::vector<std::string> coverCandidates = getCoverCandidates();
|
||||||
|
for (const auto& candidate : coverCandidates) {
|
||||||
|
effectiveCoverImageHref = candidate;
|
||||||
|
// Try to read a small amount to check if exists
|
||||||
|
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
|
||||||
|
if (test) {
|
||||||
|
free(test);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
effectiveCoverImageHref.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectiveCoverImageHref.empty()) {
|
||||||
LOG_ERR("EBP", "No known cover image");
|
LOG_ERR("EBP", "No known cover image");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
// Check for JPG/JPEG extensions (case insensitive)
|
||||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
std::string lowerHref = effectiveCoverImageHref;
|
||||||
|
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
|
||||||
|
bool isJpg =
|
||||||
|
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
|
||||||
|
if (isJpg) {
|
||||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||||
|
|
||||||
@@ -465,7 +497,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
|||||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
|
|
||||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
@@ -499,9 +531,18 @@ std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].
|
|||||||
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||||
|
|
||||||
bool Epub::generateThumbBmp(int height) const {
|
bool Epub::generateThumbBmp(int height) const {
|
||||||
|
bool invalid = false;
|
||||||
// Already generated, return true
|
// Already generated, return true
|
||||||
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||||
return true;
|
// is this a valid thumbnail or just an empty file we created to mark generation attempts?
|
||||||
|
invalid = !isValidThumbnailBmp(getThumbBmpPath(height));
|
||||||
|
if (invalid) {
|
||||||
|
// Remove the old invalid thumbnail so we can attempt to generate a new one
|
||||||
|
Storage.remove(getThumbBmpPath(height).c_str());
|
||||||
|
LOG_DBG("EBP", "Previous thumbnail generation attempt failed for height %d, retrying", height);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||||
@@ -510,52 +551,246 @@ bool Epub::generateThumbBmp(int height) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||||
|
std::string effectiveCoverImageHref = coverImageHref;
|
||||||
if (coverImageHref.empty()) {
|
if (coverImageHref.empty()) {
|
||||||
|
// Fallback: try common cover filenames
|
||||||
|
std::vector<std::string> coverCandidates = getCoverCandidates();
|
||||||
|
for (const auto& candidate : coverCandidates) {
|
||||||
|
effectiveCoverImageHref = candidate;
|
||||||
|
// Try to read a small amount to check if exists
|
||||||
|
uint8_t* test = readItemContentsToBytes(candidate, nullptr, false);
|
||||||
|
if (test) {
|
||||||
|
free(test);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
effectiveCoverImageHref.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectiveCoverImageHref.empty()) {
|
||||||
LOG_DBG("EBP", "No known cover image for thumbnail");
|
LOG_DBG("EBP", "No known cover image for thumbnail");
|
||||||
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
|
||||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
|
||||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
|
||||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
||||||
|
|
||||||
FsFile coverJpg;
|
|
||||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
|
||||||
coverJpg.close();
|
|
||||||
|
|
||||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
FsFile thumbBmp;
|
|
||||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
|
||||||
coverJpg.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
|
||||||
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
|
||||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
|
||||||
int THUMB_TARGET_HEIGHT = height;
|
|
||||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
|
||||||
THUMB_TARGET_HEIGHT);
|
|
||||||
coverJpg.close();
|
|
||||||
thumbBmp.close();
|
|
||||||
Storage.remove(coverJpgTempPath.c_str());
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
|
||||||
Storage.remove(getThumbBmpPath(height).c_str());
|
|
||||||
}
|
|
||||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
|
||||||
return success;
|
|
||||||
} else {
|
} else {
|
||||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
// Check for JPG/JPEG extensions (case insensitive)
|
||||||
|
std::string lowerHref = effectiveCoverImageHref;
|
||||||
|
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
|
||||||
|
bool isJpg =
|
||||||
|
lowerHref.substr(lowerHref.length() - 4) == ".jpg" || lowerHref.substr(lowerHref.length() - 5) == ".jpeg";
|
||||||
|
if (isJpg) {
|
||||||
|
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||||
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||||
|
|
||||||
|
FsFile coverJpg;
|
||||||
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
readItemContentsToStream(effectiveCoverImageHref, coverJpg, 1024);
|
||||||
|
coverJpg.close();
|
||||||
|
|
||||||
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile thumbBmp;
|
||||||
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||||
|
coverJpg.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
||||||
|
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||||
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||||
|
int THUMB_TARGET_HEIGHT = height;
|
||||||
|
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||||
|
THUMB_TARGET_HEIGHT);
|
||||||
|
coverJpg.close();
|
||||||
|
thumbBmp.close();
|
||||||
|
Storage.remove(coverJpgTempPath.c_str());
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||||
|
Storage.remove(getThumbBmpPath(height).c_str());
|
||||||
|
}
|
||||||
|
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||||
|
return success;
|
||||||
|
} else {
|
||||||
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Epub::generateInvalidFormatThumbBmp(int height) const {
|
||||||
|
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
|
||||||
|
// This BMP is a valid 1-bit file used as a marker to prevent repeated
|
||||||
|
// generation attempts when conversion fails (e.g., progressive JPG).
|
||||||
|
const int width = height * 0.6; // Same aspect ratio as normal thumbnails
|
||||||
|
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
|
||||||
|
const int imageSize = rowBytes * height;
|
||||||
|
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
||||||
|
const int dataOffset = 14 + 40 + 8;
|
||||||
|
|
||||||
|
FsFile thumbBmp;
|
||||||
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BMP file header (14 bytes)
|
||||||
|
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);
|
||||||
|
|
||||||
|
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
||||||
|
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; // Negative for top-down
|
||||||
|
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; // 72 DPI
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Color palette (2 colors for 1-bit)
|
||||||
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
|
||||||
|
thumbBmp.write(black, 4);
|
||||||
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
|
||||||
|
thumbBmp.write(white, 4);
|
||||||
|
|
||||||
|
// Generate X pattern bitmap data
|
||||||
|
// In BMP, 0 = black (first color in palette), 1 = white
|
||||||
|
// We'll draw black pixels on white background
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
|
||||||
|
|
||||||
|
// Map this row to a horizontal position for diagonals
|
||||||
|
const int scaledY = (y * width) / height;
|
||||||
|
const int thickness = 2; // thickness of diagonal lines in pixels
|
||||||
|
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
bool drawPixel = false;
|
||||||
|
// Main diagonal (top-left to bottom-right)
|
||||||
|
if (std::abs(x - scaledY) <= thickness) drawPixel = true;
|
||||||
|
// Other diagonal (top-right to bottom-left)
|
||||||
|
if (std::abs(x - (width - 1 - scaledY)) <= thickness) drawPixel = true;
|
||||||
|
|
||||||
|
if (drawPixel) {
|
||||||
|
const int byteIndex = x / 8;
|
||||||
|
const int bitIndex = 7 - (x % 8); // MSB first
|
||||||
|
rowData[byteIndex] &= static_cast<uint8_t>(~(1 << bitIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the row data
|
||||||
|
thumbBmp.write(rowData.data(), rowBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbBmp.close();
|
||||||
|
LOG_DBG("EBP", "Generated invalid format thumbnail BMP");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Epub::generateInvalidFormatCoverBmp(bool cropped) const {
|
||||||
|
// Create a simple 1-bit BMP with an X pattern to indicate invalid format.
|
||||||
|
// This BMP is intentionally a valid image that visually indicates a
|
||||||
|
// malformed/unsupported cover image instead of leaving an empty marker
|
||||||
|
// file that would cause repeated generation attempts.
|
||||||
|
// Derive logical portrait dimensions from the display hardware constants
|
||||||
|
// EInkDisplay reports native panel orientation as 800x480; use min/max
|
||||||
|
const int hwW = HalDisplay::DISPLAY_WIDTH;
|
||||||
|
const int hwH = HalDisplay::DISPLAY_HEIGHT;
|
||||||
|
const int width = std::min(hwW, hwH); // logical portrait width (480)
|
||||||
|
const int height = std::max(hwW, hwH); // logical portrait height (800)
|
||||||
|
const int rowBytes = ((width + 31) / 32) * 4; // 1-bit rows padded to 4-byte boundary
|
||||||
|
const int imageSize = rowBytes * height;
|
||||||
|
const int fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
||||||
|
const int dataOffset = 14 + 40 + 8;
|
||||||
|
|
||||||
|
FsFile coverBmp;
|
||||||
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BMP file header (14 bytes)
|
||||||
|
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);
|
||||||
|
|
||||||
|
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
||||||
|
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; // Negative for top-down
|
||||||
|
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; // 72 DPI
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Color palette (2 colors for 1-bit)
|
||||||
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; // Color 0: Black
|
||||||
|
coverBmp.write(black, 4);
|
||||||
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; // Color 1: White
|
||||||
|
coverBmp.write(white, 4);
|
||||||
|
|
||||||
|
// Generate X pattern bitmap data
|
||||||
|
// In BMP, 0 = black (first color in palette), 1 = white
|
||||||
|
// We'll draw black pixels on white background
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
std::vector<uint8_t> rowData(rowBytes, 0xFF); // Initialize to all white (1s)
|
||||||
|
|
||||||
|
const int scaledY = (y * width) / height;
|
||||||
|
const int thickness = 6; // thicker lines for full-cover visibility
|
||||||
|
|
||||||
|
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 {
|
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
||||||
if (itemHref.empty()) {
|
if (itemHref.empty()) {
|
||||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||||
@@ -703,3 +938,45 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
|
|||||||
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
||||||
return totalProgress / static_cast<float>(bookSize);
|
return totalProgress / static_cast<float>(bookSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Empty file is a marker for "no cover available"
|
||||||
|
LOG_DBG("EBP", "Thumbnail BMP is empty (no cover marker) at path: %s", bmpPath.c_str());
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// BMP header starts with 'B' 'M'
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
LOG_DBG("EBP", "Thumbnail BMP header: %c%c", header[0], header[1]);
|
||||||
|
file.close();
|
||||||
|
return header[0] == 'B' && header[1] == 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Epub::getCoverCandidates() const {
|
||||||
|
std::vector<std::string> coverDirectories = {".", "images", "Images", "OEBPS", "OEBPS/images", "OEBPS/Images"};
|
||||||
|
std::vector<std::string> coverExtensions = {".jpg", ".jpeg"}; // add ".png" when PNG cover support is implemented
|
||||||
|
std::vector<std::string> coverCandidates;
|
||||||
|
for (const auto& ext : coverExtensions) {
|
||||||
|
for (const auto& dir : coverDirectories) {
|
||||||
|
std::string candidate = (dir == ".") ? "cover" + ext : dir + "/cover" + ext;
|
||||||
|
coverCandidates.push_back(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coverCandidates;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,10 +52,23 @@ class Epub {
|
|||||||
const std::string& getAuthor() const;
|
const std::string& getAuthor() const;
|
||||||
const std::string& getLanguage() const;
|
const std::string& getLanguage() const;
|
||||||
std::string getCoverBmpPath(bool cropped = false) const;
|
std::string getCoverBmpPath(bool cropped = false) const;
|
||||||
|
// Generate a 1-bit BMP cover image from the EPUB cover image.
|
||||||
|
// Returns true on success. On conversion failure, callers may use
|
||||||
|
// `generateInvalidFormatCoverBmp` to create a valid marker BMP.
|
||||||
bool generateCoverBmp(bool cropped = false) const;
|
bool generateCoverBmp(bool cropped = false) const;
|
||||||
|
// Create a valid 1-bit BMP that visually indicates an invalid/unsupported
|
||||||
|
// cover format (an X pattern). This prevents repeated generation attempts
|
||||||
|
// by providing a valid BMP file that `isValidThumbnailBmp` accepts.
|
||||||
|
bool generateInvalidFormatCoverBmp(bool cropped = false) const;
|
||||||
std::string getThumbBmpPath() const;
|
std::string getThumbBmpPath() const;
|
||||||
std::string getThumbBmpPath(int height) const;
|
std::string getThumbBmpPath(int height) const;
|
||||||
|
// Generate a thumbnail BMP at the requested `height`. Returns true on
|
||||||
|
// successful conversion. If conversion fails, `generateInvalidFormatThumbBmp`
|
||||||
|
// can be used to write a valid marker image that prevents retries.
|
||||||
bool generateThumbBmp(int height) const;
|
bool generateThumbBmp(int height) const;
|
||||||
|
// Create a valid 1-bit thumbnail BMP with an X marker indicating an
|
||||||
|
// invalid/unsupported cover image instead of leaving an empty marker file.
|
||||||
|
bool generateInvalidFormatThumbBmp(int height) const;
|
||||||
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||||
bool trailingNullByte = false) const;
|
bool trailingNullByte = false) const;
|
||||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||||
@@ -72,4 +85,9 @@ class Epub {
|
|||||||
size_t getBookSize() const;
|
size_t getBookSize() const;
|
||||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||||
CssParser* getCssParser() const { return cssParser.get(); }
|
CssParser* getCssParser() const { return cssParser.get(); }
|
||||||
|
|
||||||
|
static bool isValidThumbnailBmp(const std::string& bmpPath);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::string> getCoverCandidates() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -642,11 +642,14 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
|
|
||||||
if (!lastEpub.generateCoverBmp(cropped)) {
|
if (!lastEpub.generateCoverBmp(cropped)) {
|
||||||
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
|
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
|
||||||
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
|
if (!PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
|
||||||
lastEpub.getAuthor(), 480, 800);
|
lastEpub.getAuthor(), 480, 800)) {
|
||||||
|
LOG_DBG("SLP", "Placeholder generation failed, creating X-pattern marker");
|
||||||
|
lastEpub.generateInvalidFormatCoverBmp(cropped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
|
if (!Epub::isValidThumbnailBmp(lastEpub.getCoverBmpPath(cropped))) {
|
||||||
LOG_ERR("SLP", "Failed to generate cover bmp");
|
LOG_ERR("SLP", "Failed to generate cover bmp");
|
||||||
return (this->*renderNoCoverSleepScreen)();
|
return (this->*renderNoCoverSleepScreen)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
for (RecentBook& book : recentBooks) {
|
for (RecentBook& book : recentBooks) {
|
||||||
if (!book.coverBmpPath.empty()) {
|
if (!book.coverBmpPath.empty()) {
|
||||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||||
if (!Storage.exists(coverPath.c_str())) {
|
if (!Epub::isValidThumbnailBmp(coverPath)) {
|
||||||
if (!showingLoading) {
|
if (!showingLoading) {
|
||||||
showingLoading = true;
|
showingLoading = true;
|
||||||
popupRect = GUI.drawPopup(renderer, "Loading...");
|
popupRect = GUI.drawPopup(renderer, "Loading...");
|
||||||
@@ -74,21 +74,46 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
|||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
|
|
||||||
// Try format-specific thumbnail generation first
|
// Try format-specific thumbnail generation first (Real Cover)
|
||||||
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
||||||
Epub epub(book.path, "/.crosspoint");
|
Epub epub(book.path, "/.crosspoint");
|
||||||
epub.load(false, true);
|
// Try fast cache-only load first; only build cache if missing
|
||||||
|
if (!epub.load(false, true)) {
|
||||||
|
// Cache missing — build it (may take longer)
|
||||||
|
epub.load(true, true);
|
||||||
|
}
|
||||||
success = epub.generateThumbBmp(coverHeight);
|
success = epub.generateThumbBmp(coverHeight);
|
||||||
|
if (success) {
|
||||||
|
const std::string thumbPath = epub.getThumbBmpPath(coverHeight);
|
||||||
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
|
||||||
|
book.coverBmpPath = thumbPath;
|
||||||
|
} else {
|
||||||
|
// Fallback: generate a placeholder thumbnail with title/author
|
||||||
|
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||||
|
success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||||
|
if (!success) {
|
||||||
|
// Last resort: X-pattern marker to prevent repeated generation attempts
|
||||||
|
epub.generateInvalidFormatThumbBmp(coverHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
||||||
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
||||||
Xtc xtc(book.path, "/.crosspoint");
|
Xtc xtc(book.path, "/.crosspoint");
|
||||||
if (xtc.load()) {
|
if (xtc.load()) {
|
||||||
success = xtc.generateThumbBmp(coverHeight);
|
success = xtc.generateThumbBmp(coverHeight);
|
||||||
|
if (success) {
|
||||||
|
const std::string thumbPath = xtc.getThumbBmpPath(coverHeight);
|
||||||
|
RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath);
|
||||||
|
book.coverBmpPath = thumbPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!success) {
|
||||||
|
// Fallback: generate a placeholder thumbnail with title/author
|
||||||
// Fallback: generate a placeholder thumbnail with title/author
|
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||||
if (!success) {
|
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown format: generate a placeholder thumbnail
|
||||||
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||||
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,32 +131,41 @@ void EpubReaderActivity::onEnter() {
|
|||||||
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
|
||||||
epub->generateCoverBmp(false);
|
epub->generateCoverBmp(false);
|
||||||
// Fallback: generate placeholder if real cover extraction failed
|
// Fallback: generate placeholder if real cover extraction failed
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) {
|
||||||
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), 480,
|
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(),
|
||||||
800);
|
480, 800)) {
|
||||||
|
// Last resort: X-pattern marker
|
||||||
|
epub->generateInvalidFormatCoverBmp(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
|
||||||
epub->generateCoverBmp(true);
|
epub->generateCoverBmp(true);
|
||||||
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) {
|
||||||
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480,
|
if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(),
|
||||||
800);
|
480, 800)) {
|
||||||
|
// Last resort: X-pattern marker
|
||||||
|
epub->generateInvalidFormatCoverBmp(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
|
||||||
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||||
// Fallback: generate placeholder thumbnail
|
// Fallback: generate placeholder thumbnail
|
||||||
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) {
|
||||||
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
|
||||||
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
|
||||||
PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
|
if (!PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
|
||||||
epub->getAuthor(), thumbWidth, thumbHeight);
|
epub->getAuthor(), thumbWidth, thumbHeight)) {
|
||||||
|
// Last resort: X-pattern marker
|
||||||
|
epub->generateInvalidFormatThumbBmp(thumbHeight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,11 +274,10 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
|||||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||||
i++) {
|
i++) {
|
||||||
std::string coverPath = recentBooks[i].coverBmpPath;
|
std::string coverPath = recentBooks[i].coverBmpPath;
|
||||||
bool hasCover = true;
|
|
||||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||||
if (coverPath.empty()) {
|
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||||
hasCover = false;
|
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
||||||
} else {
|
if (!coverPath.empty()) {
|
||||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
||||||
|
|
||||||
// First time: load cover from SD and render
|
// First time: load cover from SD and render
|
||||||
@@ -292,20 +291,12 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
|||||||
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
||||||
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
||||||
float cropX = 1.0f - (tileRatio / ratio);
|
float cropX = 1.0f - (tileRatio / ratio);
|
||||||
|
|
||||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
||||||
} else {
|
|
||||||
hasCover = false;
|
|
||||||
}
|
}
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCover) {
|
|
||||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
|
||||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
coverBufferStored = storeCoverBuffer();
|
coverBufferStored = storeCoverBuffer();
|
||||||
|
|||||||
Reference in New Issue
Block a user