nice
This commit is contained in:
parent
2952d7554c
commit
5c3828efe8
@ -573,6 +573,228 @@ bool Epub::generateThumbBmp() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string Epub::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; }
|
||||
|
||||
bool Epub::generateMicroThumbBmp() const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getMicroThumbBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] Cannot generate micro 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 micro thumbnail\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
Serial.printf("[%lu] [EBP] Generating micro thumb BMP from JPG cover image\n", millis());
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
// Check if temp JPEG already exists (from generateAllCovers), otherwise extract it
|
||||
bool needsCleanup = false;
|
||||
if (!SdMan.exists(coverJpgTempPath.c_str())) {
|
||||
FsFile coverJpg;
|
||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
needsCleanup = true;
|
||||
}
|
||||
|
||||
FsFile coverJpg;
|
||||
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile microThumbBmp;
|
||||
if (!SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
// Use very small target size for Recent Books list (45x60 pixels)
|
||||
// Generate 1-bit BMP for fast rendering
|
||||
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
|
||||
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(
|
||||
coverJpg, microThumbBmp, MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
microThumbBmp.close();
|
||||
|
||||
if (needsCleanup) {
|
||||
SdMan.remove(coverJpgTempPath.c_str());
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [EBP] Failed to generate micro thumb BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getMicroThumbBmpPath().c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated micro 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 micro thumbnail\n", millis());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) const {
|
||||
// Check if all covers already exist - quick exit if nothing to do
|
||||
const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str());
|
||||
const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str());
|
||||
const bool hasCoverFit = SdMan.exists(getCoverBmpPath(false).c_str());
|
||||
const bool hasCoverCrop = SdMan.exists(getCoverBmpPath(true).c_str());
|
||||
|
||||
if (hasThumb && hasMicroThumb && hasCoverFit && hasCoverCrop) {
|
||||
Serial.printf("[%lu] [EBP] All covers already cached\n", millis());
|
||||
if (progressCallback) progressCallback(100);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] Cannot generate covers, cache not loaded\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only process JPG/JPEG covers
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) != ".jpg" &&
|
||||
coverImageHref.substr(coverImageHref.length() - 5) != ".jpeg") {
|
||||
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping all cover generation\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EBP] Generating all covers (thumb:%d, micro:%d, fit:%d, crop:%d)\n", millis(), !hasThumb,
|
||||
!hasMicroThumb, !hasCoverFit, !hasCoverCrop);
|
||||
|
||||
// Extract JPEG once to temp file
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
{
|
||||
FsFile coverJpg;
|
||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
Serial.printf("[%lu] [EBP] Failed to create temp cover file\n", millis());
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
}
|
||||
|
||||
// Get JPEG dimensions once for FIT/CROP calculations
|
||||
int jpegWidth = 0, jpegHeight = 0;
|
||||
{
|
||||
FsFile coverJpg;
|
||||
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
JpegToBmpConverter::getJpegDimensions(coverJpg, jpegWidth, jpegHeight);
|
||||
coverJpg.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Progress tracking: 4 covers = 25% each
|
||||
// Helper to create sub-progress callback that maps 0-100% to a portion of overall progress
|
||||
auto makeSubProgress = [&progressCallback](int startPercent, int endPercent) {
|
||||
if (!progressCallback) return std::function<void(int)>(nullptr);
|
||||
return std::function<void(int)>([&progressCallback, startPercent, endPercent](int subPercent) {
|
||||
const int overallProgress = startPercent + (subPercent * (endPercent - startPercent)) / 100;
|
||||
progressCallback(overallProgress);
|
||||
});
|
||||
};
|
||||
|
||||
// Generate thumb (240x400, 1-bit) if missing - progress 0-25%
|
||||
if (!hasThumb) {
|
||||
FsFile coverJpg, thumbBmp;
|
||||
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
||||
SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(
|
||||
coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT, makeSubProgress(0, 25));
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
if (!success) {
|
||||
SdMan.remove(getThumbBmpPath().c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated thumb: %s\n", millis(), success ? "yes" : "no");
|
||||
}
|
||||
}
|
||||
if (progressCallback) progressCallback(25);
|
||||
|
||||
// Generate micro thumb (45x60, 1-bit) if missing - progress 25-50%
|
||||
if (!hasMicroThumb) {
|
||||
FsFile coverJpg, microThumbBmp;
|
||||
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
||||
SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) {
|
||||
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
|
||||
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(
|
||||
coverJpg, microThumbBmp, MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT, makeSubProgress(25, 50));
|
||||
coverJpg.close();
|
||||
microThumbBmp.close();
|
||||
if (!success) {
|
||||
SdMan.remove(getMicroThumbBmpPath().c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated micro thumb: %s\n", millis(), success ? "yes" : "no");
|
||||
}
|
||||
}
|
||||
if (progressCallback) progressCallback(50);
|
||||
|
||||
// Generate cover_fit (480xProportional, 2-bit) if missing - progress 50-75%
|
||||
if (!hasCoverFit && jpegWidth > 0 && jpegHeight > 0) {
|
||||
FsFile coverJpg, coverBmp;
|
||||
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
||||
SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) {
|
||||
const int targetWidth = 480;
|
||||
const int targetHeight = (480 * jpegHeight) / jpegWidth;
|
||||
const bool success =
|
||||
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(50, 75));
|
||||
coverJpg.close();
|
||||
coverBmp.close();
|
||||
if (!success) {
|
||||
SdMan.remove(getCoverBmpPath(false).c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated cover_fit: %s\n", millis(), success ? "yes" : "no");
|
||||
}
|
||||
}
|
||||
if (progressCallback) progressCallback(75);
|
||||
|
||||
// Generate cover_crop (Proportionalx800, 2-bit) if missing - progress 75-100%
|
||||
if (!hasCoverCrop && jpegWidth > 0 && jpegHeight > 0) {
|
||||
FsFile coverJpg, coverBmp;
|
||||
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
||||
SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) {
|
||||
const int targetHeight = 800;
|
||||
const int targetWidth = (800 * jpegWidth) / jpegHeight;
|
||||
const bool success =
|
||||
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(75, 100));
|
||||
coverJpg.close();
|
||||
coverBmp.close();
|
||||
if (!success) {
|
||||
SdMan.remove(getCoverBmpPath(true).c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated cover_crop: %s\n", millis(), success ? "yes" : "no");
|
||||
}
|
||||
}
|
||||
if (progressCallback) progressCallback(100);
|
||||
|
||||
// Clean up temp JPEG
|
||||
SdMan.remove(coverJpgTempPath.c_str());
|
||||
Serial.printf("[%lu] [EBP] All cover generation complete\n", millis());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <Print.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@ -53,6 +54,9 @@ class Epub {
|
||||
bool generateCoverBmp(bool cropped = false) const;
|
||||
std::string getThumbBmpPath() const;
|
||||
bool generateThumbBmp() const;
|
||||
std::string getMicroThumbBmpPath() const;
|
||||
bool generateMicroThumbBmp() const;
|
||||
bool generateAllCovers(const std::function<void(int)>& progressCallback = nullptr) 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;
|
||||
|
||||
@ -153,10 +153,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||
const float cropX, const float cropY) const {
|
||||
const float cropX, const float cropY, const bool invert) const {
|
||||
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
|
||||
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
|
||||
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
|
||||
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight, invert);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -264,7 +264,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
|
||||
const int maxHeight) const {
|
||||
const int maxHeight, const bool invert) const {
|
||||
float scale = 1.0f;
|
||||
|
||||
// Calculate scale to fit within maxWidth/maxHeight (supports both up and down scaling)
|
||||
@ -324,8 +324,12 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
|
||||
|
||||
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
|
||||
// val < 3 means black pixel (draw it)
|
||||
if (val < 3) {
|
||||
// val < 3 means black pixel, val == 3 means white pixel
|
||||
// When inverted: draw white pixels as black, skip black pixels
|
||||
const bool isBlackPixel = (val < 3);
|
||||
const bool shouldDraw = invert ? !isBlackPixel : isBlackPixel;
|
||||
|
||||
if (shouldDraw) {
|
||||
// Draw to all X positions this source pixel maps to (for upscaling, this fills gaps)
|
||||
for (int screenX = screenXStart; screenX < screenXEnd; screenX++) {
|
||||
if (screenX < 0) continue;
|
||||
@ -333,7 +337,6 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
drawPixel(screenX, screenY, true);
|
||||
}
|
||||
}
|
||||
// White pixels (val == 3) are not drawn (leave background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,8 +67,8 @@ class GfxRenderer {
|
||||
void fillRect(int x, int y, int width, int height, bool state = true) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||
float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
float cropY = 0, bool invert = false) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const;
|
||||
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
|
||||
|
||||
// Text
|
||||
|
||||
@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
|
||||
|
||||
// Internal implementation with configurable target size and bit depth
|
||||
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
||||
bool oneBit) {
|
||||
bool oneBit, const std::function<void(int)>& progressCallback) {
|
||||
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
|
||||
targetWidth, targetHeight);
|
||||
|
||||
@ -524,6 +524,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report progress after each MCU row
|
||||
if (progressCallback) {
|
||||
const int progress = ((mcuY + 1) * 100) / imageInfo.m_MCUSPerCol;
|
||||
progressCallback(progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
@ -551,19 +557,21 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
|
||||
// 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, false);
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, nullptr);
|
||||
}
|
||||
|
||||
// Convert with custom target size (for thumbnails, 2-bit)
|
||||
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
|
||||
int targetMaxHeight) {
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
|
||||
int targetMaxHeight,
|
||||
const std::function<void(int)>& progressCallback) {
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false, progressCallback);
|
||||
}
|
||||
|
||||
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
|
||||
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
|
||||
int targetMaxHeight) {
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
|
||||
int targetMaxHeight,
|
||||
const std::function<void(int)>& progressCallback) {
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, progressCallback);
|
||||
}
|
||||
|
||||
// Get JPEG dimensions without full conversion
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
class FsFile;
|
||||
class Print;
|
||||
class ZipFile;
|
||||
@ -8,14 +10,16 @@ class JpegToBmpConverter {
|
||||
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,
|
||||
bool oneBit);
|
||||
bool oneBit, const std::function<void(int)>& progressCallback = nullptr);
|
||||
|
||||
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);
|
||||
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight,
|
||||
const std::function<void(int)>& progressCallback = nullptr);
|
||||
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
|
||||
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
|
||||
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight,
|
||||
const std::function<void(int)>& progressCallback = nullptr);
|
||||
// Get JPEG dimensions without full conversion
|
||||
static bool getJpegDimensions(FsFile& jpegFile, int& width, int& height);
|
||||
};
|
||||
|
||||
183
lib/Txt/Txt.cpp
183
lib/Txt/Txt.cpp
@ -169,6 +169,189 @@ bool Txt::generateCoverBmp() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
|
||||
|
||||
bool Txt::generateThumbBmp() const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string coverImagePath = findCoverImage();
|
||||
if (coverImagePath.empty()) {
|
||||
Serial.printf("[%lu] [TXT] No cover image found for thumbnail\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setup cache directory
|
||||
setupCacheDir();
|
||||
|
||||
// Get file extension
|
||||
const size_t len = coverImagePath.length();
|
||||
const bool isJpg =
|
||||
(len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
|
||||
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
|
||||
|
||||
if (isJpg) {
|
||||
// Convert JPG to 1-bit BMP thumbnail
|
||||
Serial.printf("[%lu] [TXT] Generating thumb BMP from JPG cover image\n", millis());
|
||||
FsFile coverJpg, thumbBmp;
|
||||
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
if (!SdMan.openFileForWrite("TXT", getThumbBmpPath(), thumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
const bool success =
|
||||
JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [TXT] Failed to generate thumb BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getThumbBmpPath().c_str());
|
||||
} else {
|
||||
Serial.printf("[%lu] [TXT] Generated thumb BMP from JPG cover image\n", millis());
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// For BMP files, just copy cover.bmp to thumb.bmp (no scaling for BMP)
|
||||
if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) {
|
||||
FsFile src, dst;
|
||||
if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) {
|
||||
if (SdMan.openFileForWrite("TXT", 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] [TXT] Copied cover to thumb\n", millis());
|
||||
return SdMan.exists(getThumbBmpPath().c_str());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string Txt::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; }
|
||||
|
||||
bool Txt::generateMicroThumbBmp() const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getMicroThumbBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string coverImagePath = findCoverImage();
|
||||
if (coverImagePath.empty()) {
|
||||
Serial.printf("[%lu] [TXT] No cover image found for micro thumbnail\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setup cache directory
|
||||
setupCacheDir();
|
||||
|
||||
// Get file extension
|
||||
const size_t len = coverImagePath.length();
|
||||
const bool isJpg =
|
||||
(len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
|
||||
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
|
||||
|
||||
if (isJpg) {
|
||||
// Convert JPG to 1-bit BMP micro thumbnail
|
||||
Serial.printf("[%lu] [TXT] Generating micro thumb BMP from JPG cover image\n", millis());
|
||||
FsFile coverJpg, microThumbBmp;
|
||||
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
if (!SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), microThumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
|
||||
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, microThumbBmp,
|
||||
MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
microThumbBmp.close();
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [TXT] Failed to generate micro thumb BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getMicroThumbBmpPath().c_str());
|
||||
} else {
|
||||
Serial.printf("[%lu] [TXT] Generated micro thumb BMP from JPG cover image\n", millis());
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// For BMP files, just copy cover.bmp to micro_thumb.bmp (no scaling for BMP)
|
||||
if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) {
|
||||
FsFile src, dst;
|
||||
if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) {
|
||||
if (SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), 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] [TXT] Copied cover to micro thumb\n", millis());
|
||||
return SdMan.exists(getMicroThumbBmpPath().c_str());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Txt::generateAllCovers(const std::function<void(int)>& progressCallback) const {
|
||||
// Check if all covers already exist
|
||||
const bool hasCover = SdMan.exists(getCoverBmpPath().c_str());
|
||||
const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str());
|
||||
const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str());
|
||||
|
||||
if (hasCover && hasThumb && hasMicroThumb) {
|
||||
Serial.printf("[%lu] [TXT] All covers already cached\n", millis());
|
||||
if (progressCallback) progressCallback(100);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string coverImagePath = findCoverImage();
|
||||
if (coverImagePath.empty()) {
|
||||
Serial.printf("[%lu] [TXT] No cover image found, skipping cover generation\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [TXT] Generating all covers (cover:%d, thumb:%d, micro:%d)\n", millis(), !hasCover, !hasThumb,
|
||||
!hasMicroThumb);
|
||||
|
||||
// Generate each cover type that's missing with progress updates
|
||||
if (!hasCover) {
|
||||
(void)generateCoverBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(33);
|
||||
|
||||
if (!hasThumb) {
|
||||
(void)generateThumbBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(66);
|
||||
|
||||
if (!hasMicroThumb) {
|
||||
(void)generateMicroThumbBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(100);
|
||||
|
||||
Serial.printf("[%lu] [TXT] All cover generation complete\n", millis());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
|
||||
if (!loaded) {
|
||||
return false;
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@ -27,6 +28,14 @@ class Txt {
|
||||
[[nodiscard]] std::string getCoverBmpPath() const;
|
||||
[[nodiscard]] bool generateCoverBmp() const;
|
||||
[[nodiscard]] std::string findCoverImage() const;
|
||||
// Thumbnail support (for Continue Reading card)
|
||||
[[nodiscard]] std::string getThumbBmpPath() const;
|
||||
[[nodiscard]] bool generateThumbBmp() const;
|
||||
// Micro thumbnail support (for Recent Books list)
|
||||
[[nodiscard]] std::string getMicroThumbBmpPath() const;
|
||||
[[nodiscard]] bool generateMicroThumbBmp() const;
|
||||
// Generate all covers at once (for pre-generation on book open)
|
||||
[[nodiscard]] bool generateAllCovers(const std::function<void(int)>& progressCallback = nullptr) const;
|
||||
|
||||
// Read content from file
|
||||
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||
|
||||
267
lib/Xtc/Xtc.cpp
267
lib/Xtc/Xtc.cpp
@ -554,6 +554,273 @@ bool Xtc::generateThumbBmp() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Xtc::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; }
|
||||
|
||||
bool Xtc::generateMicroThumbBmp() const {
|
||||
// Already generated
|
||||
if (SdMan.exists(getMicroThumbBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!loaded || !parser) {
|
||||
Serial.printf("[%lu] [XTC] Cannot generate micro 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 micro thumbnail (45x60 for Recent Books list)
|
||||
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
|
||||
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
|
||||
|
||||
// Calculate scale factor to fit within target dimensions
|
||||
float scaleX = static_cast<float>(MICRO_THUMB_TARGET_WIDTH) / pageInfo.width;
|
||||
float scaleY = static_cast<float>(MICRO_THUMB_TARGET_HEIGHT) / pageInfo.height;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
|
||||
uint16_t microThumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
|
||||
uint16_t microThumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
|
||||
|
||||
// Ensure minimum size
|
||||
if (microThumbWidth < 1) microThumbWidth = 1;
|
||||
if (microThumbHeight < 1) microThumbHeight = 1;
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generating micro thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
|
||||
pageInfo.height, microThumbWidth, microThumbHeight, 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 micro thumb\n", millis());
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create micro thumbnail BMP file - use 1-bit format
|
||||
FsFile microThumbBmp;
|
||||
if (!SdMan.openFileForWrite("XTC", getMicroThumbBmpPath(), microThumbBmp)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to create micro thumb BMP file\n", millis());
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write 1-bit BMP header
|
||||
const uint32_t rowSize = (microThumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
|
||||
const uint32_t imageSize = rowSize * microThumbHeight;
|
||||
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
|
||||
|
||||
// File header
|
||||
microThumbBmp.write('B');
|
||||
microThumbBmp.write('M');
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
||||
uint32_t reserved = 0;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
||||
uint32_t dataOffset = 14 + 40 + 8;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
||||
|
||||
// DIB header
|
||||
uint32_t dibHeaderSize = 40;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
||||
int32_t widthVal = microThumbWidth;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
|
||||
int32_t heightVal = -static_cast<int32_t>(microThumbHeight); // Negative for top-down
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
|
||||
uint16_t planes = 1;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
||||
uint16_t bitsPerPixel = 1;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
||||
uint32_t compression = 0;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
||||
int32_t ppmX = 2835;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
||||
int32_t ppmY = 2835;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
||||
uint32_t colorsUsed = 2;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
||||
uint32_t colorsImportant = 2;
|
||||
microThumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
||||
|
||||
// Color palette
|
||||
uint8_t palette[8] = {
|
||||
0x00, 0x00, 0x00, 0x00, // Color 0: Black
|
||||
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
|
||||
};
|
||||
microThumbBmp.write(palette, 8);
|
||||
|
||||
// Allocate row buffer
|
||||
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
|
||||
if (!rowBuffer) {
|
||||
free(pageBuffer);
|
||||
microThumbBmp.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 < microThumbHeight; dstY++) {
|
||||
memset(rowBuffer, 0xFF, rowSize); // Start with all white
|
||||
|
||||
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 < microThumbWidth; dstX++) {
|
||||
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
|
||||
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;
|
||||
|
||||
if (bitDepth == 2) {
|
||||
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;
|
||||
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;
|
||||
grayValue = (3 - pixelValue) * 85;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const size_t byteIdx = srcY * srcRowBytes + srcX / 8;
|
||||
const size_t bitIdx = 7 - (srcX % 8);
|
||||
if (byteIdx < bitmapSize) {
|
||||
const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1;
|
||||
grayValue = pixelBit ? 255 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
graySum += grayValue;
|
||||
totalCount++;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255;
|
||||
|
||||
// Hash-based noise dithering
|
||||
uint32_t hash = static_cast<uint32_t>(dstX) * 374761393u + static_cast<uint32_t>(dstY) * 668265263u;
|
||||
hash = (hash ^ (hash >> 13)) * 1274126177u;
|
||||
const int threshold = static_cast<int>(hash >> 24);
|
||||
const int adjustedThreshold = 128 + ((threshold - 128) / 2);
|
||||
|
||||
uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0;
|
||||
|
||||
const size_t byteIndex = dstX / 8;
|
||||
const size_t bitOffset = 7 - (dstX % 8);
|
||||
if (byteIndex < rowSize) {
|
||||
if (oneBit) {
|
||||
rowBuffer[byteIndex] |= (1 << bitOffset);
|
||||
} else {
|
||||
rowBuffer[byteIndex] &= ~(1 << bitOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
microThumbBmp.write(rowBuffer, rowSize);
|
||||
}
|
||||
|
||||
free(rowBuffer);
|
||||
microThumbBmp.close();
|
||||
free(pageBuffer);
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generated micro thumb BMP (%dx%d): %s\n", millis(), microThumbWidth, microThumbHeight,
|
||||
getMicroThumbBmpPath().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Xtc::generateAllCovers(const std::function<void(int)>& progressCallback) const {
|
||||
// Check if all covers already exist
|
||||
const bool hasCover = SdMan.exists(getCoverBmpPath().c_str());
|
||||
const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str());
|
||||
const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str());
|
||||
|
||||
if (hasCover && hasThumb && hasMicroThumb) {
|
||||
Serial.printf("[%lu] [XTC] All covers already cached\n", millis());
|
||||
if (progressCallback) progressCallback(100);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!loaded || !parser) {
|
||||
Serial.printf("[%lu] [XTC] Cannot generate covers, file not loaded\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generating all covers (cover:%d, thumb:%d, micro:%d)\n", millis(), !hasCover, !hasThumb,
|
||||
!hasMicroThumb);
|
||||
|
||||
// Generate each cover type that's missing with progress updates
|
||||
if (!hasCover) {
|
||||
generateCoverBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(33);
|
||||
|
||||
if (!hasThumb) {
|
||||
generateThumbBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(66);
|
||||
|
||||
if (!hasMicroThumb) {
|
||||
generateMicroThumbBmp();
|
||||
}
|
||||
if (progressCallback) progressCallback(100);
|
||||
|
||||
Serial.printf("[%lu] [XTC] All cover generation complete\n", millis());
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t Xtc::getPageCount() const {
|
||||
if (!loaded || !parser) {
|
||||
return 0;
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@ -65,6 +66,11 @@ class Xtc {
|
||||
// Thumbnail support (for Continue Reading card)
|
||||
std::string getThumbBmpPath() const;
|
||||
bool generateThumbBmp() const;
|
||||
// Micro thumbnail support (for Recent Books list)
|
||||
std::string getMicroThumbBmpPath() const;
|
||||
bool generateMicroThumbBmp() const;
|
||||
// Generate all covers at once (for pre-generation on book open)
|
||||
bool generateAllCovers(const std::function<void(int)>& progressCallback = nullptr) const;
|
||||
|
||||
// Page access
|
||||
uint32_t getPageCount() const;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "MyLibraryActivity.h"
|
||||
|
||||
#include <Bitmap.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
@ -20,6 +21,24 @@ constexpr int LINE_HEIGHT = 30;
|
||||
constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items
|
||||
constexpr int LEFT_MARGIN = 20;
|
||||
constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator
|
||||
constexpr int MICRO_THUMB_WIDTH = 45;
|
||||
constexpr int MICRO_THUMB_HEIGHT = 60;
|
||||
constexpr int THUMB_RIGHT_MARGIN = 50; // Space from right edge for thumbnail
|
||||
|
||||
// Helper function to get the micro-thumb path for a book based on its file path
|
||||
std::string getMicroThumbPathForBook(const std::string& bookPath) {
|
||||
// Calculate cache path using same hash method as Epub/Xtc/Txt classes
|
||||
const size_t hash = std::hash<std::string>{}(bookPath);
|
||||
|
||||
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
|
||||
return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp";
|
||||
} else if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) {
|
||||
return "/.crosspoint/xtc_" + std::to_string(hash) + "/micro_thumb.bmp";
|
||||
} else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT")) {
|
||||
return "/.crosspoint/txt_" + std::to_string(hash) + "/micro_thumb.bmp";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Timing thresholds
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
@ -481,10 +500,49 @@ void MyLibraryActivity::renderRecentTab() const {
|
||||
renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2,
|
||||
pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT);
|
||||
|
||||
// Calculate available text width (leaving space for thumbnail on the right)
|
||||
const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10;
|
||||
const int thumbX = pageWidth - THUMB_RIGHT_MARGIN - MICRO_THUMB_WIDTH;
|
||||
|
||||
// Draw items
|
||||
for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) {
|
||||
const auto& book = recentBooks[i];
|
||||
const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT;
|
||||
const bool isSelected = (i == selectorIndex);
|
||||
|
||||
// Try to load and draw micro-thumbnail
|
||||
const std::string microThumbPath = getMicroThumbPathForBook(book.path);
|
||||
bool hasThumb = false;
|
||||
if (!microThumbPath.empty() && SdMan.exists(microThumbPath.c_str())) {
|
||||
FsFile thumbFile;
|
||||
if (SdMan.openFileForRead("MYL", microThumbPath, thumbFile)) {
|
||||
Bitmap bitmap(thumbFile);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
// Calculate actual drawn size (scaled to fit within max dimensions, preserving aspect ratio)
|
||||
const int bmpW = bitmap.getWidth();
|
||||
const int bmpH = bitmap.getHeight();
|
||||
const float scaleX = static_cast<float>(MICRO_THUMB_WIDTH) / static_cast<float>(bmpW);
|
||||
const float scaleY = static_cast<float>(MICRO_THUMB_HEIGHT) / static_cast<float>(bmpH);
|
||||
const float scale = std::min(scaleX, scaleY);
|
||||
const int drawnW = static_cast<int>(bmpW * scale);
|
||||
const int drawnH = static_cast<int>(bmpH * scale);
|
||||
|
||||
// Center thumbnail vertically within the row using actual drawn height
|
||||
const int thumbY = y + (RECENTS_LINE_HEIGHT - drawnH) / 2;
|
||||
// When selected, clear only the actual drawn area to white first
|
||||
// (drawBitmap1Bit only draws pixels, it doesn't clear, so we need the white background)
|
||||
if (isSelected) {
|
||||
renderer.fillRect(thumbX, thumbY, drawnW, drawnH, false);
|
||||
}
|
||||
renderer.drawBitmap(bitmap, thumbX, thumbY, MICRO_THUMB_WIDTH, MICRO_THUMB_HEIGHT, 0, 0, isSelected);
|
||||
hasThumb = true;
|
||||
}
|
||||
thumbFile.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Use full width if no thumbnail, otherwise use reduced width
|
||||
const int availableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
||||
|
||||
// Line 1: Title
|
||||
std::string title = book.title;
|
||||
@ -500,14 +558,13 @@ void MyLibraryActivity::renderRecentTab() const {
|
||||
title.resize(dot);
|
||||
}
|
||||
}
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
||||
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex);
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth);
|
||||
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected);
|
||||
|
||||
// Line 2: Author
|
||||
if (!book.author.empty()) {
|
||||
auto truncatedAuthor =
|
||||
renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
||||
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex);
|
||||
auto truncatedAuthor = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), availableWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,47 @@ void EpubReaderActivity::onEnter() {
|
||||
|
||||
epub->setupCacheDir();
|
||||
|
||||
// Check if cover generation is needed and do it NOW (blocking)
|
||||
const bool needsThumb = !SdMan.exists(epub->getThumbBmpPath().c_str());
|
||||
const bool needsMicroThumb = !SdMan.exists(epub->getMicroThumbBmpPath().c_str());
|
||||
const bool needsCoverFit = !SdMan.exists(epub->getCoverBmpPath(false).c_str());
|
||||
const bool needsCoverCrop = !SdMan.exists(epub->getCoverBmpPath(true).c_str());
|
||||
|
||||
if (needsThumb || needsMicroThumb || needsCoverFit || needsCoverCrop) {
|
||||
// Show "Preparing book... [X%]" popup, updating every 3 seconds
|
||||
constexpr int boxMargin = 20;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]");
|
||||
const int boxWidth = textWidth + boxMargin * 2;
|
||||
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||
constexpr int boxY = 50;
|
||||
|
||||
unsigned long lastUpdate = 0;
|
||||
|
||||
// Draw initial popup
|
||||
renderer.clearScreen();
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
|
||||
// Generate covers with progress callback
|
||||
epub->generateAllCovers([&](int percent) {
|
||||
const unsigned long now = millis();
|
||||
if ((now - lastUpdate) >= 3000) {
|
||||
lastUpdate = now;
|
||||
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
|
||||
char progressStr[32];
|
||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
FsFile f;
|
||||
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
@ -486,6 +527,7 @@ void EpubReaderActivity::renderScreen() {
|
||||
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
|
||||
}
|
||||
|
||||
// Save progress
|
||||
FsFile f;
|
||||
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
|
||||
@ -56,6 +56,47 @@ void TxtReaderActivity::onEnter() {
|
||||
|
||||
txt->setupCacheDir();
|
||||
|
||||
// Check if cover generation is needed and do it NOW (blocking)
|
||||
const bool needsCover = !SdMan.exists(txt->getCoverBmpPath().c_str());
|
||||
const bool needsThumb = !SdMan.exists(txt->getThumbBmpPath().c_str());
|
||||
const bool needsMicroThumb = !SdMan.exists(txt->getMicroThumbBmpPath().c_str());
|
||||
const bool hasCoverImage = !txt->findCoverImage().empty();
|
||||
|
||||
if (hasCoverImage && (needsCover || needsThumb || needsMicroThumb)) {
|
||||
// Show "Preparing book... [X%]" popup, updating every 3 seconds
|
||||
constexpr int boxMargin = 20;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]");
|
||||
const int boxWidth = textWidth + boxMargin * 2;
|
||||
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||
constexpr int boxY = 50;
|
||||
|
||||
unsigned long lastUpdate = 0;
|
||||
|
||||
// Draw initial popup
|
||||
renderer.clearScreen();
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
|
||||
// Generate covers with progress callback
|
||||
txt->generateAllCovers([&](int percent) {
|
||||
const unsigned long now = millis();
|
||||
if ((now - lastUpdate) >= 3000) {
|
||||
lastUpdate = now;
|
||||
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
|
||||
char progressStr[32];
|
||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save current txt as last opened file
|
||||
APP_STATE.openEpubPath = txt->getPath();
|
||||
APP_STATE.saveToFile();
|
||||
@ -445,8 +486,6 @@ void TxtReaderActivity::renderScreen() {
|
||||
|
||||
renderer.clearScreen();
|
||||
renderPage();
|
||||
|
||||
// Save progress
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,46 @@ void XtcReaderActivity::onEnter() {
|
||||
|
||||
xtc->setupCacheDir();
|
||||
|
||||
// Check if cover generation is needed and do it NOW (blocking)
|
||||
const bool needsCover = !SdMan.exists(xtc->getCoverBmpPath().c_str());
|
||||
const bool needsThumb = !SdMan.exists(xtc->getThumbBmpPath().c_str());
|
||||
const bool needsMicroThumb = !SdMan.exists(xtc->getMicroThumbBmpPath().c_str());
|
||||
|
||||
if (needsCover || needsThumb || needsMicroThumb) {
|
||||
// Show "Preparing book... [X%]" popup, updating every 3 seconds
|
||||
constexpr int boxMargin = 20;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]");
|
||||
const int boxWidth = textWidth + boxMargin * 2;
|
||||
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||
constexpr int boxY = 50;
|
||||
|
||||
unsigned long lastUpdate = 0;
|
||||
|
||||
// Draw initial popup
|
||||
renderer.clearScreen();
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
|
||||
// Generate covers with progress callback
|
||||
xtc->generateAllCovers([&](int percent) {
|
||||
const unsigned long now = millis();
|
||||
if ((now - lastUpdate) >= 3000) {
|
||||
lastUpdate = now;
|
||||
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
|
||||
char progressStr[32];
|
||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved progress
|
||||
loadProgress();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user