2025-12-28 23:56:05 +09:00
|
|
|
/**
|
|
|
|
|
* Xtc.cpp
|
|
|
|
|
*
|
|
|
|
|
* Main XTC ebook class implementation
|
|
|
|
|
* XTC ebook support for CrossPoint Reader
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include "Xtc.h"
|
|
|
|
|
|
|
|
|
|
#include <FsHelpers.h>
|
|
|
|
|
#include <HardwareSerial.h>
|
2025-12-30 15:09:30 +10:00
|
|
|
#include <SDCardManager.h>
|
2025-12-28 23:56:05 +09:00
|
|
|
|
|
|
|
|
bool Xtc::load() {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
|
|
|
|
|
|
|
|
|
|
// Initialize parser
|
|
|
|
|
parser.reset(new xtc::XtcParser());
|
|
|
|
|
|
|
|
|
|
// Open XTC file
|
|
|
|
|
xtc::XtcError err = parser->open(filepath.c_str());
|
|
|
|
|
if (err != xtc::XtcError::OK) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err));
|
|
|
|
|
parser.reset();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loaded = true;
|
|
|
|
|
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Xtc::clearCache() const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.exists(cachePath.c_str())) {
|
2025-12-28 23:56:05 +09:00
|
|
|
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.removeDir(cachePath.c_str())) {
|
2025-12-28 23:56:05 +09:00
|
|
|
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Xtc::setupCacheDir() const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (SdMan.exists(cachePath.c_str())) {
|
2025-12-28 23:56:05 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create directories recursively
|
|
|
|
|
for (size_t i = 1; i < cachePath.length(); i++) {
|
|
|
|
|
if (cachePath[i] == '/') {
|
2025-12-30 15:09:30 +10:00
|
|
|
SdMan.mkdir(cachePath.substr(0, i).c_str());
|
2025-12-28 23:56:05 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-30 15:09:30 +10:00
|
|
|
SdMan.mkdir(cachePath.c_str());
|
2025-12-28 23:56:05 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string Xtc::getTitle() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get title from XTC metadata first
|
|
|
|
|
std::string title = parser->getTitle();
|
|
|
|
|
if (!title.empty()) {
|
|
|
|
|
return title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: extract filename from path as title
|
|
|
|
|
size_t lastSlash = filepath.find_last_of('/');
|
|
|
|
|
size_t lastDot = filepath.find_last_of('.');
|
|
|
|
|
|
|
|
|
|
if (lastSlash == std::string::npos) {
|
|
|
|
|
lastSlash = 0;
|
|
|
|
|
} else {
|
|
|
|
|
lastSlash++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lastDot == std::string::npos || lastDot <= lastSlash) {
|
|
|
|
|
return filepath.substr(lastSlash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return filepath.substr(lastSlash, lastDot - lastSlash);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 12:49:18 +11:00
|
|
|
bool Xtc::hasChapters() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return parser->hasChapters();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::vector<xtc::ChapterInfo>& Xtc::getChapters() const {
|
|
|
|
|
static const std::vector<xtc::ChapterInfo> kEmpty;
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return kEmpty;
|
|
|
|
|
}
|
|
|
|
|
return parser->getChapters();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 23:56:05 +09:00
|
|
|
std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
|
|
|
|
|
|
|
|
|
bool Xtc::generateCoverBmp() const {
|
|
|
|
|
// Already generated
|
2025-12-30 15:09:30 +10:00
|
|
|
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
2025-12-28 23:56:05 +09:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Cannot generate cover 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();
|
|
|
|
|
|
|
|
|
|
// Allocate buffer for page data
|
|
|
|
|
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
|
|
|
|
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
|
|
|
|
|
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\n", millis());
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create BMP file
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile coverBmp;
|
|
|
|
|
if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
|
2025-12-28 23:56:05 +09:00
|
|
|
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis());
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write BMP header
|
|
|
|
|
// BMP file header (14 bytes)
|
|
|
|
|
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes
|
|
|
|
|
const uint32_t imageSize = rowSize * pageInfo.height;
|
|
|
|
|
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
|
|
|
|
|
|
|
|
|
// File header
|
|
|
|
|
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);
|
|
|
|
|
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
|
|
|
|
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 width = pageInfo.width;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&width), 4);
|
|
|
|
|
int32_t height = -static_cast<int32_t>(pageInfo.height); // Negative for top-down
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&height), 4);
|
|
|
|
|
uint16_t planes = 1;
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
|
|
|
uint16_t bitsPerPixel = 1; // 1-bit monochrome
|
|
|
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
|
|
|
uint32_t compression = 0; // BI_RGB (no compression)
|
|
|
|
|
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)
|
2026-01-19 20:41:48 +09:00
|
|
|
// XTC 1-bit polarity: 0 = black, 1 = white (standard BMP palette order)
|
2025-12-28 23:56:05 +09:00
|
|
|
// Color 0: Black (text/foreground in XTC)
|
|
|
|
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
|
|
|
|
|
coverBmp.write(black, 4);
|
|
|
|
|
// Color 1: White (background in XTC)
|
|
|
|
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
|
|
|
|
|
coverBmp.write(white, 4);
|
|
|
|
|
|
|
|
|
|
// Write bitmap data
|
|
|
|
|
// BMP requires 4-byte row alignment
|
|
|
|
|
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
|
|
|
|
|
|
|
|
|
|
if (bitDepth == 2) {
|
|
|
|
|
// XTH 2-bit mode: Two bit planes, column-major order
|
|
|
|
|
// - Columns scanned right to left (x = width-1 down to 0)
|
|
|
|
|
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
|
|
|
|
// - First plane: Bit1, Second plane: Bit2
|
|
|
|
|
// - Pixel value = (bit1 << 1) | bit2
|
|
|
|
|
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
|
|
|
|
|
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
|
|
|
|
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
|
|
|
|
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
|
|
|
|
|
|
|
|
|
|
// Allocate a row buffer for 1-bit output
|
|
|
|
|
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
|
|
|
|
|
if (!rowBuffer) {
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
|
|
|
|
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
|
|
|
|
|
|
|
|
|
|
for (uint16_t x = 0; x < pageInfo.width; x++) {
|
|
|
|
|
// Column-major, right to left: column index = (width - 1 - x)
|
|
|
|
|
const size_t colIndex = pageInfo.width - 1 - x;
|
|
|
|
|
const size_t byteInCol = y / 8;
|
|
|
|
|
const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel
|
|
|
|
|
|
|
|
|
|
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
|
|
|
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
|
|
|
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
|
|
|
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
|
|
|
|
|
|
|
|
|
// Threshold: 0=white (1); 1,2,3=black (0)
|
|
|
|
|
if (pixelValue >= 1) {
|
|
|
|
|
// Set bit to 0 (black) in BMP format
|
|
|
|
|
const size_t dstByte = x / 8;
|
|
|
|
|
const size_t dstBit = 7 - (x % 8);
|
|
|
|
|
rowBuffer[dstByte] &= ~(1 << dstBit);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write converted row
|
|
|
|
|
coverBmp.write(rowBuffer, dstRowSize);
|
|
|
|
|
|
|
|
|
|
// Pad to 4-byte boundary
|
|
|
|
|
uint8_t padding[4] = {0, 0, 0, 0};
|
|
|
|
|
size_t paddingSize = rowSize - dstRowSize;
|
|
|
|
|
if (paddingSize > 0) {
|
|
|
|
|
coverBmp.write(padding, paddingSize);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(rowBuffer);
|
|
|
|
|
} else {
|
|
|
|
|
// 1-bit source: write directly with proper padding
|
|
|
|
|
const size_t srcRowSize = (pageInfo.width + 7) / 8;
|
|
|
|
|
|
|
|
|
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
|
|
|
|
// Write source row
|
|
|
|
|
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
|
|
|
|
|
|
|
|
|
|
// Pad to 4-byte boundary
|
|
|
|
|
uint8_t padding[4] = {0, 0, 0, 0};
|
|
|
|
|
size_t paddingSize = rowSize - srcRowSize;
|
|
|
|
|
if (paddingSize > 0) {
|
|
|
|
|
coverBmp.write(padding, paddingSize);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
|
|
|
|
|
|
|
|
|
|
bool Xtc::generateThumbBmp() const {
|
|
|
|
|
// Already generated
|
|
|
|
|
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parser->getPageCount() == 0) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup cache directory
|
|
|
|
|
setupCacheDir();
|
|
|
|
|
|
|
|
|
|
// Get first page info for cover
|
|
|
|
|
xtc::PageInfo pageInfo;
|
|
|
|
|
if (!parser->getPageInfo(0, pageInfo)) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get bit depth
|
|
|
|
|
const uint8_t bitDepth = parser->getBitDepth();
|
|
|
|
|
|
|
|
|
|
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
|
|
|
|
|
constexpr int THUMB_TARGET_WIDTH = 240;
|
|
|
|
|
constexpr int THUMB_TARGET_HEIGHT = 400;
|
|
|
|
|
|
|
|
|
|
// Calculate scale factor
|
|
|
|
|
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
|
|
|
|
|
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
|
|
|
|
|
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
|
|
|
|
|
|
|
|
// Only scale down, never up
|
|
|
|
|
if (scale >= 1.0f) {
|
|
|
|
|
// Page is already small enough, just use cover.bmp
|
|
|
|
|
// Copy cover.bmp to thumb.bmp
|
|
|
|
|
if (generateCoverBmp()) {
|
|
|
|
|
FsFile src, dst;
|
|
|
|
|
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
|
|
|
|
|
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
|
|
|
|
|
uint8_t buffer[512];
|
|
|
|
|
while (src.available()) {
|
|
|
|
|
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
|
|
|
|
dst.write(buffer, bytesRead);
|
|
|
|
|
}
|
|
|
|
|
dst.close();
|
|
|
|
|
}
|
|
|
|
|
src.close();
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
|
|
|
|
|
return SdMan.exists(getThumbBmpPath().c_str());
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
|
|
|
|
|
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
|
|
|
|
|
pageInfo.height, thumbWidth, thumbHeight, scale);
|
|
|
|
|
|
|
|
|
|
// Allocate buffer for page data
|
|
|
|
|
size_t bitmapSize;
|
|
|
|
|
if (bitDepth == 2) {
|
|
|
|
|
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
|
|
|
|
|
} else {
|
|
|
|
|
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
|
|
|
|
|
}
|
|
|
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
|
|
|
|
if (!pageBuffer) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load first page (cover)
|
|
|
|
|
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
|
|
|
|
|
if (bytesRead == 0) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis());
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
|
|
|
|
|
FsFile thumbBmp;
|
|
|
|
|
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
|
|
|
|
|
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write 1-bit BMP header for fast home screen rendering
|
|
|
|
|
const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
|
|
|
|
|
const uint32_t imageSize = rowSize * thumbHeight;
|
|
|
|
|
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
|
|
|
|
|
|
|
|
|
|
// File header
|
|
|
|
|
thumbBmp.write('B');
|
|
|
|
|
thumbBmp.write('M');
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
|
|
|
|
uint32_t reserved = 0;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
|
|
|
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
|
|
|
|
|
|
// DIB header
|
|
|
|
|
uint32_t dibHeaderSize = 40;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
|
|
|
int32_t widthVal = thumbWidth;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
|
|
|
|
|
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
|
|
|
|
|
uint16_t planes = 1;
|
|
|
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
|
|
|
uint16_t bitsPerPixel = 1; // 1-bit for black and white
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Color palette (2 colors for 1-bit: black and white)
|
|
|
|
|
uint8_t palette[8] = {
|
|
|
|
|
0x00, 0x00, 0x00, 0x00, // Color 0: Black
|
|
|
|
|
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
|
|
|
|
|
};
|
|
|
|
|
thumbBmp.write(palette, 8);
|
|
|
|
|
|
|
|
|
|
// Allocate row buffer for 1-bit output
|
|
|
|
|
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
|
|
|
|
|
if (!rowBuffer) {
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
thumbBmp.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fixed-point scale factor (16.16)
|
|
|
|
|
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
|
|
|
|
|
|
|
|
|
|
// Pre-calculate plane info for 2-bit mode
|
|
|
|
|
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0;
|
|
|
|
|
const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr;
|
|
|
|
|
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
|
|
|
|
|
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
|
|
|
|
|
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
|
|
|
|
|
|
|
|
|
|
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
|
|
|
|
|
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
|
|
|
|
|
|
|
|
|
|
// Calculate source Y range with bounds checking
|
|
|
|
|
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
|
|
|
|
|
uint32_t srcYEnd = (static_cast<uint32_t>(dstY + 1) * scaleInv_fp) >> 16;
|
|
|
|
|
if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1;
|
|
|
|
|
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
|
|
|
|
|
if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1;
|
|
|
|
|
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
|
|
|
|
|
|
|
|
|
|
for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) {
|
|
|
|
|
// Calculate source X range with bounds checking
|
|
|
|
|
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
|
|
|
|
|
uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16;
|
|
|
|
|
if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1;
|
|
|
|
|
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
|
|
|
|
|
if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1;
|
|
|
|
|
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
|
|
|
|
|
|
|
|
|
|
// Area averaging: sum grayscale values (0-255 range)
|
|
|
|
|
uint32_t graySum = 0;
|
|
|
|
|
uint32_t totalCount = 0;
|
|
|
|
|
|
|
|
|
|
for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) {
|
|
|
|
|
for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) {
|
|
|
|
|
uint8_t grayValue = 255; // Default: white
|
|
|
|
|
|
|
|
|
|
if (bitDepth == 2) {
|
|
|
|
|
// XTH 2-bit mode: pixel value 0-3
|
|
|
|
|
// Bounds check for column index
|
|
|
|
|
if (srcX < pageInfo.width) {
|
|
|
|
|
const size_t colIndex = pageInfo.width - 1 - srcX;
|
|
|
|
|
const size_t byteInCol = srcY / 8;
|
|
|
|
|
const size_t bitInByte = 7 - (srcY % 8);
|
|
|
|
|
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
|
|
|
|
// Bounds check for buffer access
|
|
|
|
|
if (byteOffset < planeSize) {
|
|
|
|
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
|
|
|
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
|
|
|
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
|
|
|
|
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
|
|
|
|
|
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
|
|
|
|
|
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 1-bit mode
|
|
|
|
|
const size_t byteIdx = srcY * srcRowBytes + srcX / 8;
|
|
|
|
|
const size_t bitIdx = 7 - (srcX % 8);
|
|
|
|
|
// Bounds check for buffer access
|
|
|
|
|
if (byteIdx < bitmapSize) {
|
|
|
|
|
const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1;
|
2026-01-19 20:41:48 +09:00
|
|
|
// XTC 1-bit polarity: 0=black, 1=white (same as BMP palette)
|
|
|
|
|
grayValue = pixelBit ? 255 : 0;
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
graySum += grayValue;
|
|
|
|
|
totalCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate average grayscale and quantize to 1-bit with noise dithering
|
|
|
|
|
uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255;
|
|
|
|
|
|
|
|
|
|
// Hash-based noise dithering for 1-bit output
|
|
|
|
|
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); // 0-255
|
|
|
|
|
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
|
|
|
|
|
|
|
|
|
// Quantize to 1-bit: 0=black, 1=white
|
|
|
|
|
uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0;
|
|
|
|
|
|
|
|
|
|
// Pack 1-bit value into row buffer (MSB first, 8 pixels per byte)
|
|
|
|
|
const size_t byteIndex = dstX / 8;
|
|
|
|
|
const size_t bitOffset = 7 - (dstX % 8);
|
|
|
|
|
// Bounds check for row buffer access
|
|
|
|
|
if (byteIndex < rowSize) {
|
|
|
|
|
if (oneBit) {
|
|
|
|
|
rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white
|
|
|
|
|
} else {
|
|
|
|
|
rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write row (already padded to 4-byte boundary by rowSize)
|
|
|
|
|
thumbBmp.write(rowBuffer, rowSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(rowBuffer);
|
|
|
|
|
thumbBmp.close();
|
|
|
|
|
free(pageBuffer);
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
|
|
|
|
|
getThumbBmpPath().c_str());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 02:01:53 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 23:56:05 +09:00
|
|
|
uint32_t Xtc::getPageCount() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return parser->getPageCount();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint16_t Xtc::getPageWidth() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return parser->getWidth();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint16_t Xtc::getPageHeight() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return parser->getHeight();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint8_t Xtc::getBitDepth() const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return 1; // Default to 1-bit
|
|
|
|
|
}
|
|
|
|
|
return parser->getBitDepth();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return const_cast<xtc::XtcParser*>(parser.get())->loadPage(pageIndex, buffer, bufferSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex,
|
|
|
|
|
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
|
|
|
size_t chunkSize) const {
|
|
|
|
|
if (!loaded || !parser) {
|
|
|
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
|
|
|
}
|
|
|
|
|
return const_cast<xtc::XtcParser*>(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint8_t Xtc::calculateProgress(uint32_t currentPage) const {
|
|
|
|
|
if (!loaded || !parser || parser->getPageCount() == 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return static_cast<uint8_t>((currentPage + 1) * 100 / parser->getPageCount());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
xtc::XtcError Xtc::getLastError() const {
|
|
|
|
|
if (!parser) {
|
|
|
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
|
|
|
}
|
|
|
|
|
return parser->getLastError();
|
|
|
|
|
}
|