refactor: Memory optimization and XTC format removal
Memory optimization: - Add LOG_STACK_WATERMARK macro for task stack monitoring - Add freeCoverBufferIfAllocated() and preloadCoverBuffer() for memory management - Improve cover buffer reuse to reduce heap fragmentation - Add grayscale buffer cleanup safety in GfxRenderer - Make grayscale rendering conditional on successful buffer allocation - Add detailed heap fragmentation logging with ESP-IDF API - Add CSS parser memory usage estimation XTC format removal: - Remove entire lib/Xtc library (XTC parser and types) - Remove XtcReaderActivity and XtcReaderChapterSelectionActivity - Remove XTC file handling from HomeActivity, SleepActivity, ReaderActivity - Remove .xtc/.xtch from supported extensions in BookManager - Remove XTC cache prefix from Md5Utils - Update web server and file browser to exclude XTC format - Clear in-memory caches when disk cache is cleared
This commit is contained in:
parent
158caacfe0
commit
c2a966a6ea
@ -1,12 +1,7 @@
|
|||||||
## Feature Requests:
|
## Feature Requests:
|
||||||
|
|
||||||
1) Ability to clear all books and clear individual books from Recents.
|
1) Ability to clear all books and clear individual books from Recents.
|
||||||
2) Use the suffix of the filename, and extension, to apply "tags" to books
|
2) Bookmarks
|
||||||
- render badges in Recents/Lists views.
|
|
||||||
- for extension ".epub":"epub", ".txt":"txt", ".md":"md".
|
|
||||||
- for suffix: "-x4":"X4", "-x4p":"X4+", "-og":"OG".
|
|
||||||
- badges would follow the books's title: "Atlas Shrugged [epub] [OG]".
|
|
||||||
- badges should be in "pill" form (see sample image attached).
|
|
||||||
3) ability to add/remove books from lists on device.
|
3) ability to add/remove books from lists on device.
|
||||||
4) quick menu
|
4) quick menu
|
||||||
5) hide "system folders" from files view
|
5) hide "system folders" from files view
|
||||||
|
|||||||
@ -251,8 +251,8 @@ bool Epub::parseCssFiles() {
|
|||||||
SdMan.remove(tmpCssPath.c_str());
|
SdMan.remove(tmpCssPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(),
|
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(), cssParser->ruleCount(),
|
||||||
cssFiles.size());
|
cssFiles.size(), cssParser->estimateMemoryUsage());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,25 @@ class CssParser {
|
|||||||
*/
|
*/
|
||||||
[[nodiscard]] size_t ruleCount() const { return rulesBySelector_.size(); }
|
[[nodiscard]] size_t ruleCount() const { return rulesBySelector_.size(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate memory usage of loaded rules (for debugging)
|
||||||
|
* Returns approximate bytes used by selector strings and style data
|
||||||
|
*/
|
||||||
|
[[nodiscard]] size_t estimateMemoryUsage() const {
|
||||||
|
size_t bytes = 0;
|
||||||
|
// unordered_map overhead: roughly 8 bytes per bucket + per-entry overhead
|
||||||
|
bytes += rulesBySelector_.bucket_count() * sizeof(void*);
|
||||||
|
for (const auto& entry : rulesBySelector_) {
|
||||||
|
// String storage: capacity + SSO overhead (~24 bytes) + actual chars
|
||||||
|
bytes += sizeof(std::string) + entry.first.capacity();
|
||||||
|
// CssStyle is ~16 bytes
|
||||||
|
bytes += sizeof(CssStyle);
|
||||||
|
// Per-entry node overhead in unordered_map (~24-32 bytes)
|
||||||
|
bytes += 32;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all loaded rules
|
* Clear all loaded rules
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -857,7 +857,7 @@ bool GfxRenderer::storeBwBuffer() {
|
|||||||
* Uses chunked restoration to match chunked storage.
|
* Uses chunked restoration to match chunked storage.
|
||||||
*/
|
*/
|
||||||
void GfxRenderer::restoreBwBuffer() {
|
void GfxRenderer::restoreBwBuffer() {
|
||||||
// Check if any all chunks are allocated
|
// Check if all chunks are allocated
|
||||||
bool missingChunks = false;
|
bool missingChunks = false;
|
||||||
for (const auto& bwBufferChunk : bwBufferChunks) {
|
for (const auto& bwBufferChunk : bwBufferChunks) {
|
||||||
if (!bwBufferChunk) {
|
if (!bwBufferChunk) {
|
||||||
@ -868,6 +868,13 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
|
|
||||||
if (missingChunks) {
|
if (missingChunks) {
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
|
// CRITICAL: Even if restore fails, we must clean up the grayscale state
|
||||||
|
// to prevent grayscaleRevert() from being called with corrupted RAM state
|
||||||
|
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
|
||||||
|
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
||||||
|
if (frameBuffer) {
|
||||||
|
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -883,6 +890,8 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
if (!bwBufferChunks[i]) {
|
if (!bwBufferChunks[i]) {
|
||||||
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
|
// CRITICAL: Clean up grayscale state even on mid-restore failure
|
||||||
|
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
# XTC/XTCH Library
|
|
||||||
|
|
||||||
XTC ebook format support for CrossPoint Reader.
|
|
||||||
|
|
||||||
## Supported Formats
|
|
||||||
|
|
||||||
| Format | Extension | Description |
|
|
||||||
|--------|-----------|----------------------------------------------|
|
|
||||||
| XTC | `.xtc` | Container with XTG pages (1-bit monochrome) |
|
|
||||||
| XTCH | `.xtch` | Container with XTH pages (2-bit grayscale) |
|
|
||||||
|
|
||||||
## Format Overview
|
|
||||||
|
|
||||||
XTC/XTCH are container formats designed for ESP32 e-paper displays. They store pre-rendered bitmap pages optimized for the XTeink X4 e-reader (480x800 resolution).
|
|
||||||
|
|
||||||
### Container Structure (XTC/XTCH)
|
|
||||||
|
|
||||||
- 56-byte header with metadata offsets
|
|
||||||
- Optional metadata (title, author, etc.)
|
|
||||||
- Page index table (16 bytes per page)
|
|
||||||
- Page data (XTG or XTH format)
|
|
||||||
|
|
||||||
### Page Formats
|
|
||||||
|
|
||||||
#### XTG (1-bit monochrome)
|
|
||||||
|
|
||||||
- Row-major storage, 8 pixels per byte
|
|
||||||
- MSB first (bit 7 = leftmost pixel)
|
|
||||||
- 0 = Black, 1 = White
|
|
||||||
|
|
||||||
#### XTH (2-bit grayscale)
|
|
||||||
|
|
||||||
- Two bit planes stored sequentially
|
|
||||||
- Column-major order (right to left)
|
|
||||||
- 8 vertical pixels per byte
|
|
||||||
- Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
Original format info: <https://gist.github.com/CrazyCoder/b125f26d6987c0620058249f59f1327d>
|
|
||||||
880
lib/Xtc/Xtc.cpp
880
lib/Xtc/Xtc.cpp
@ -1,880 +0,0 @@
|
|||||||
/**
|
|
||||||
* Xtc.cpp
|
|
||||||
*
|
|
||||||
* Main XTC ebook class implementation
|
|
||||||
* XTC ebook support for CrossPoint Reader
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "Xtc.h"
|
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
|
||||||
#include <HardwareSerial.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (!SdMan.exists(cachePath.c_str())) {
|
|
||||||
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!SdMan.removeDir(cachePath.c_str())) {
|
|
||||||
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 {
|
|
||||||
if (SdMan.exists(cachePath.c_str())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create directories recursively
|
|
||||||
for (size_t i = 1; i < cachePath.length(); i++) {
|
|
||||||
if (cachePath[i] == '/') {
|
|
||||||
SdMan.mkdir(cachePath.substr(0, i).c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SdMan.mkdir(cachePath.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
|
||||||
|
|
||||||
bool Xtc::generateCoverBmp() const {
|
|
||||||
// Already generated
|
|
||||||
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
|
||||||
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
|
|
||||||
FsFile coverBmp;
|
|
||||||
if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
|
|
||||||
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)
|
|
||||||
// XTC 1-bit polarity: 0 = black, 1 = white (standard BMP palette order)
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
// XTC 1-bit polarity: 0=black, 1=white (same as BMP palette)
|
|
||||||
grayValue = pixelBit ? 255 : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
109
lib/Xtc/Xtc.h
109
lib/Xtc/Xtc.h
@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Xtc.h
|
|
||||||
*
|
|
||||||
* Main XTC ebook class for CrossPoint Reader
|
|
||||||
* Provides EPUB-like interface for XTC file handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "Xtc/XtcParser.h"
|
|
||||||
#include "Xtc/XtcTypes.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XTC Ebook Handler
|
|
||||||
*
|
|
||||||
* Handles XTC file loading, page access, and cover image generation.
|
|
||||||
* Interface is designed to be similar to Epub class for easy integration.
|
|
||||||
*/
|
|
||||||
class Xtc {
|
|
||||||
std::string filepath;
|
|
||||||
std::string cachePath;
|
|
||||||
std::unique_ptr<xtc::XtcParser> parser;
|
|
||||||
bool loaded;
|
|
||||||
|
|
||||||
public:
|
|
||||||
explicit Xtc(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)), loaded(false) {
|
|
||||||
// Create cache key based on filepath (same as Epub)
|
|
||||||
cachePath = cacheDir + "/xtc_" + std::to_string(std::hash<std::string>{}(this->filepath));
|
|
||||||
}
|
|
||||||
~Xtc() = default;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load XTC file
|
|
||||||
* @return true on success
|
|
||||||
*/
|
|
||||||
bool load();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cached data
|
|
||||||
* @return true on success
|
|
||||||
*/
|
|
||||||
bool clearCache() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup cache directory
|
|
||||||
*/
|
|
||||||
void setupCacheDir() const;
|
|
||||||
|
|
||||||
// Path accessors
|
|
||||||
const std::string& getCachePath() const { return cachePath; }
|
|
||||||
const std::string& getPath() const { return filepath; }
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
std::string getTitle() const;
|
|
||||||
bool hasChapters() const;
|
|
||||||
const std::vector<xtc::ChapterInfo>& getChapters() const;
|
|
||||||
|
|
||||||
// Cover image support (for sleep screen)
|
|
||||||
std::string getCoverBmpPath() const;
|
|
||||||
bool generateCoverBmp() const;
|
|
||||||
// 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;
|
|
||||||
uint16_t getPageWidth() const;
|
|
||||||
uint16_t getPageHeight() const;
|
|
||||||
uint8_t getBitDepth() const; // 1 = XTC (1-bit), 2 = XTCH (2-bit)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load page bitmap data
|
|
||||||
* @param pageIndex Page index (0-based)
|
|
||||||
* @param buffer Output buffer
|
|
||||||
* @param bufferSize Buffer size
|
|
||||||
* @return Number of bytes read
|
|
||||||
*/
|
|
||||||
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load page with streaming callback
|
|
||||||
* @param pageIndex Page index
|
|
||||||
* @param callback Callback for each chunk
|
|
||||||
* @param chunkSize Chunk size
|
|
||||||
* @return Error code
|
|
||||||
*/
|
|
||||||
xtc::XtcError loadPageStreaming(uint32_t pageIndex,
|
|
||||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
||||||
size_t chunkSize = 1024) const;
|
|
||||||
|
|
||||||
// Progress calculation
|
|
||||||
uint8_t calculateProgress(uint32_t currentPage) const;
|
|
||||||
|
|
||||||
// Check if file is loaded
|
|
||||||
bool isLoaded() const { return loaded; }
|
|
||||||
|
|
||||||
// Error information
|
|
||||||
xtc::XtcError getLastError() const;
|
|
||||||
};
|
|
||||||
@ -1,439 +0,0 @@
|
|||||||
/**
|
|
||||||
* XtcParser.cpp
|
|
||||||
*
|
|
||||||
* XTC file parsing implementation
|
|
||||||
* XTC ebook support for CrossPoint Reader
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "XtcParser.h"
|
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
|
||||||
#include <HardwareSerial.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
namespace xtc {
|
|
||||||
|
|
||||||
XtcParser::XtcParser()
|
|
||||||
: m_isOpen(false),
|
|
||||||
m_defaultWidth(DISPLAY_WIDTH),
|
|
||||||
m_defaultHeight(DISPLAY_HEIGHT),
|
|
||||||
m_bitDepth(1),
|
|
||||||
m_hasChapters(false),
|
|
||||||
m_lastError(XtcError::OK) {
|
|
||||||
memset(&m_header, 0, sizeof(m_header));
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcParser::~XtcParser() { close(); }
|
|
||||||
|
|
||||||
XtcError XtcParser::open(const char* filepath) {
|
|
||||||
// Close if already open
|
|
||||||
if (m_isOpen) {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open file
|
|
||||||
if (!SdMan.openFileForRead("XTC", filepath, m_file)) {
|
|
||||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
|
||||||
return m_lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read header
|
|
||||||
m_lastError = readHeader();
|
|
||||||
if (m_lastError != XtcError::OK) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError));
|
|
||||||
m_file.close();
|
|
||||||
return m_lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read title if available
|
|
||||||
readTitle();
|
|
||||||
|
|
||||||
// Read page table
|
|
||||||
m_lastError = readPageTable();
|
|
||||||
if (m_lastError != XtcError::OK) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError));
|
|
||||||
m_file.close();
|
|
||||||
return m_lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read chapters if present
|
|
||||||
m_lastError = readChapters();
|
|
||||||
if (m_lastError != XtcError::OK) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to read chapters: %s\n", millis(), errorToString(m_lastError));
|
|
||||||
m_file.close();
|
|
||||||
return m_lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_isOpen = true;
|
|
||||||
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount,
|
|
||||||
m_defaultWidth, m_defaultHeight);
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcParser::close() {
|
|
||||||
if (m_isOpen) {
|
|
||||||
m_file.close();
|
|
||||||
m_isOpen = false;
|
|
||||||
}
|
|
||||||
m_pageTable.clear();
|
|
||||||
m_chapters.clear();
|
|
||||||
m_title.clear();
|
|
||||||
m_hasChapters = false;
|
|
||||||
memset(&m_header, 0, sizeof(m_header));
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcError XtcParser::readHeader() {
|
|
||||||
// Read first 56 bytes of header
|
|
||||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&m_header), sizeof(XtcHeader));
|
|
||||||
if (bytesRead != sizeof(XtcHeader)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify magic number (accept both XTC and XTCH)
|
|
||||||
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
|
|
||||||
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic,
|
|
||||||
XTC_MAGIC, XTCH_MAGIC);
|
|
||||||
return XtcError::INVALID_MAGIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine bit depth from file magic
|
|
||||||
m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1;
|
|
||||||
|
|
||||||
// Check version
|
|
||||||
// Currently, version 1.0 is the only valid version, however some generators are swapping the bytes around, so we
|
|
||||||
// accept both 1.0 and 0.1 for compatibility
|
|
||||||
const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 ||
|
|
||||||
m_header.versionMajor == 0 && m_header.versionMinor == 1;
|
|
||||||
if (!validVersion) {
|
|
||||||
Serial.printf("[%lu] [XTC] Unsupported version: %u.%u\n", millis(), m_header.versionMajor, m_header.versionMinor);
|
|
||||||
return XtcError::INVALID_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (m_header.pageCount == 0) {
|
|
||||||
return XtcError::CORRUPTED_HEADER;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
|
|
||||||
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor,
|
|
||||||
m_header.pageCount, m_bitDepth);
|
|
||||||
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcError XtcParser::readTitle() {
|
|
||||||
// Title is usually at offset 0x38 (56) for 88-byte headers
|
|
||||||
// Read title as null-terminated UTF-8 string
|
|
||||||
if (m_header.titleOffset == 0) {
|
|
||||||
m_header.titleOffset = 0x38; // Default offset
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_file.seek(m_header.titleOffset)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
char titleBuf[128] = {0};
|
|
||||||
m_file.read(reinterpret_cast<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
|
|
||||||
m_title = titleBuf;
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcError XtcParser::readPageTable() {
|
|
||||||
if (m_header.pageTableOffset == 0) {
|
|
||||||
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
|
||||||
return XtcError::CORRUPTED_HEADER;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek to page table
|
|
||||||
if (!m_file.seek(m_header.pageTableOffset)) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset);
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_pageTable.resize(m_header.pageCount);
|
|
||||||
|
|
||||||
// Read page table entries
|
|
||||||
for (uint16_t i = 0; i < m_header.pageCount; i++) {
|
|
||||||
PageTableEntry entry;
|
|
||||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
|
||||||
if (bytesRead != sizeof(PageTableEntry)) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i);
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
|
|
||||||
m_pageTable[i].size = entry.dataSize;
|
|
||||||
m_pageTable[i].width = entry.width;
|
|
||||||
m_pageTable[i].height = entry.height;
|
|
||||||
m_pageTable[i].bitDepth = m_bitDepth;
|
|
||||||
|
|
||||||
// Update default dimensions from first page
|
|
||||||
if (i == 0) {
|
|
||||||
m_defaultWidth = entry.width;
|
|
||||||
m_defaultHeight = entry.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount);
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcError XtcParser::readChapters() {
|
|
||||||
m_hasChapters = false;
|
|
||||||
m_chapters.clear();
|
|
||||||
|
|
||||||
uint8_t hasChaptersFlag = 0;
|
|
||||||
if (!m_file.seek(0x0B)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
if (m_file.read(&hasChaptersFlag, sizeof(hasChaptersFlag)) != sizeof(hasChaptersFlag)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChaptersFlag != 1) {
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint64_t chapterOffset = 0;
|
|
||||||
if (!m_file.seek(0x30)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
if (m_file.read(reinterpret_cast<uint8_t*>(&chapterOffset), sizeof(chapterOffset)) != sizeof(chapterOffset)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapterOffset == 0) {
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint64_t fileSize = m_file.size();
|
|
||||||
if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize || chapterOffset + 96 > fileSize) {
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint64_t maxOffset = 0;
|
|
||||||
if (m_header.pageTableOffset > chapterOffset) {
|
|
||||||
maxOffset = m_header.pageTableOffset;
|
|
||||||
} else if (m_header.dataOffset > chapterOffset) {
|
|
||||||
maxOffset = m_header.dataOffset;
|
|
||||||
} else {
|
|
||||||
maxOffset = fileSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxOffset <= chapterOffset) {
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr size_t chapterSize = 96;
|
|
||||||
const uint64_t available = maxOffset - chapterOffset;
|
|
||||||
const size_t chapterCount = static_cast<size_t>(available / chapterSize);
|
|
||||||
if (chapterCount == 0) {
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_file.seek(chapterOffset)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<uint8_t> chapterBuf(chapterSize);
|
|
||||||
for (size_t i = 0; i < chapterCount; i++) {
|
|
||||||
if (m_file.read(chapterBuf.data(), chapterSize) != chapterSize) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
char nameBuf[81];
|
|
||||||
memcpy(nameBuf, chapterBuf.data(), 80);
|
|
||||||
nameBuf[80] = '\0';
|
|
||||||
const size_t nameLen = strnlen(nameBuf, 80);
|
|
||||||
std::string name(nameBuf, nameLen);
|
|
||||||
|
|
||||||
uint16_t startPage = 0;
|
|
||||||
uint16_t endPage = 0;
|
|
||||||
memcpy(&startPage, chapterBuf.data() + 0x50, sizeof(startPage));
|
|
||||||
memcpy(&endPage, chapterBuf.data() + 0x52, sizeof(endPage));
|
|
||||||
|
|
||||||
if (name.empty() && startPage == 0 && endPage == 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startPage > 0) {
|
|
||||||
startPage--;
|
|
||||||
}
|
|
||||||
if (endPage > 0) {
|
|
||||||
endPage--;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startPage >= m_header.pageCount) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endPage >= m_header.pageCount) {
|
|
||||||
endPage = m_header.pageCount - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startPage > endPage) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChapterInfo chapter{std::move(name), startPage, endPage};
|
|
||||||
m_chapters.push_back(std::move(chapter));
|
|
||||||
}
|
|
||||||
|
|
||||||
m_hasChapters = !m_chapters.empty();
|
|
||||||
Serial.printf("[%lu] [XTC] Chapters: %u\n", millis(), static_cast<unsigned int>(m_chapters.size()));
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
|
|
||||||
if (pageIndex >= m_pageTable.size()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
info = m_pageTable[pageIndex];
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) {
|
|
||||||
if (!m_isOpen) {
|
|
||||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageIndex >= m_header.pageCount) {
|
|
||||||
m_lastError = XtcError::PAGE_OUT_OF_RANGE;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PageInfo& page = m_pageTable[pageIndex];
|
|
||||||
|
|
||||||
// Seek to page data
|
|
||||||
if (!m_file.seek(page.offset)) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset);
|
|
||||||
m_lastError = XtcError::READ_ERROR;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read page header (XTG for 1-bit, XTH for 2-bit - same structure)
|
|
||||||
XtgPageHeader pageHeader;
|
|
||||||
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
|
|
||||||
if (headerRead != sizeof(XtgPageHeader)) {
|
|
||||||
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex);
|
|
||||||
m_lastError = XtcError::READ_ERROR;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify page magic (XTG for 1-bit, XTH for 2-bit)
|
|
||||||
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
|
|
||||||
if (pageHeader.magic != expectedMagic) {
|
|
||||||
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex,
|
|
||||||
pageHeader.magic, expectedMagic);
|
|
||||||
m_lastError = XtcError::INVALID_MAGIC;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate bitmap size based on bit depth
|
|
||||||
// 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 (m_bitDepth == 2) {
|
|
||||||
// XTH: two bit planes, each containing (width * height) bits rounded up to bytes
|
|
||||||
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
|
|
||||||
} else {
|
|
||||||
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check buffer size
|
|
||||||
if (bufferSize < bitmapSize) {
|
|
||||||
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize);
|
|
||||||
m_lastError = XtcError::MEMORY_ERROR;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read bitmap data
|
|
||||||
size_t bytesRead = m_file.read(buffer, bitmapSize);
|
|
||||||
if (bytesRead != bitmapSize) {
|
|
||||||
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead);
|
|
||||||
m_lastError = XtcError::READ_ERROR;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_lastError = XtcError::OK;
|
|
||||||
return bytesRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
|
|
||||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
||||||
size_t chunkSize) {
|
|
||||||
if (!m_isOpen) {
|
|
||||||
return XtcError::FILE_NOT_FOUND;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageIndex >= m_header.pageCount) {
|
|
||||||
return XtcError::PAGE_OUT_OF_RANGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PageInfo& page = m_pageTable[pageIndex];
|
|
||||||
|
|
||||||
// Seek to page data
|
|
||||||
if (!m_file.seek(page.offset)) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and skip page header (XTG for 1-bit, XTH for 2-bit)
|
|
||||||
XtgPageHeader pageHeader;
|
|
||||||
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
|
|
||||||
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
|
|
||||||
if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate bitmap size based on bit depth
|
|
||||||
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
|
||||||
// XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes
|
|
||||||
size_t bitmapSize;
|
|
||||||
if (m_bitDepth == 2) {
|
|
||||||
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
|
|
||||||
} else {
|
|
||||||
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read in chunks
|
|
||||||
std::vector<uint8_t> chunk(chunkSize);
|
|
||||||
size_t totalRead = 0;
|
|
||||||
|
|
||||||
while (totalRead < bitmapSize) {
|
|
||||||
size_t toRead = std::min(chunkSize, bitmapSize - totalRead);
|
|
||||||
size_t bytesRead = m_file.read(chunk.data(), toRead);
|
|
||||||
|
|
||||||
if (bytesRead == 0) {
|
|
||||||
return XtcError::READ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(chunk.data(), bytesRead, totalRead);
|
|
||||||
totalRead += bytesRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
return XtcError::OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool XtcParser::isValidXtcFile(const char* filepath) {
|
|
||||||
FsFile file;
|
|
||||||
if (!SdMan.openFileForRead("XTC", filepath, file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t magic = 0;
|
|
||||||
size_t bytesRead = file.read(reinterpret_cast<uint8_t*>(&magic), sizeof(magic));
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
if (bytesRead != sizeof(magic)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (magic == XTC_MAGIC || magic == XTCH_MAGIC);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace xtc
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* XtcParser.h
|
|
||||||
*
|
|
||||||
* XTC file parsing and page data extraction
|
|
||||||
* XTC ebook support for CrossPoint Reader
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <SdFat.h>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "XtcTypes.h"
|
|
||||||
|
|
||||||
namespace xtc {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XTC File Parser
|
|
||||||
*
|
|
||||||
* Reads XTC files from SD card and extracts page data.
|
|
||||||
* Designed for ESP32-C3's limited RAM (~380KB) using streaming.
|
|
||||||
*/
|
|
||||||
class XtcParser {
|
|
||||||
public:
|
|
||||||
XtcParser();
|
|
||||||
~XtcParser();
|
|
||||||
|
|
||||||
// File open/close
|
|
||||||
XtcError open(const char* filepath);
|
|
||||||
void close();
|
|
||||||
bool isOpen() const { return m_isOpen; }
|
|
||||||
|
|
||||||
// Header information access
|
|
||||||
const XtcHeader& getHeader() const { return m_header; }
|
|
||||||
uint16_t getPageCount() const { return m_header.pageCount; }
|
|
||||||
uint16_t getWidth() const { return m_defaultWidth; }
|
|
||||||
uint16_t getHeight() const { return m_defaultHeight; }
|
|
||||||
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
|
|
||||||
|
|
||||||
// Page information
|
|
||||||
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load page bitmap (raw 1-bit data, skipping XTG header)
|
|
||||||
*
|
|
||||||
* @param pageIndex Page index (0-based)
|
|
||||||
* @param buffer Output buffer (caller allocated)
|
|
||||||
* @param bufferSize Buffer size
|
|
||||||
* @return Number of bytes read on success, 0 on failure
|
|
||||||
*/
|
|
||||||
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streaming page load
|
|
||||||
* Memory-efficient method that reads page data in chunks.
|
|
||||||
*
|
|
||||||
* @param pageIndex Page index
|
|
||||||
* @param callback Callback function to receive data chunks
|
|
||||||
* @param chunkSize Chunk size (default: 1024 bytes)
|
|
||||||
* @return Error code
|
|
||||||
*/
|
|
||||||
XtcError loadPageStreaming(uint32_t pageIndex,
|
|
||||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
||||||
size_t chunkSize = 1024);
|
|
||||||
|
|
||||||
// Get title from metadata
|
|
||||||
std::string getTitle() const { return m_title; }
|
|
||||||
|
|
||||||
bool hasChapters() const { return m_hasChapters; }
|
|
||||||
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
static bool isValidXtcFile(const char* filepath);
|
|
||||||
|
|
||||||
// Error information
|
|
||||||
XtcError getLastError() const { return m_lastError; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
FsFile m_file;
|
|
||||||
bool m_isOpen;
|
|
||||||
XtcHeader m_header;
|
|
||||||
std::vector<PageInfo> m_pageTable;
|
|
||||||
std::vector<ChapterInfo> m_chapters;
|
|
||||||
std::string m_title;
|
|
||||||
uint16_t m_defaultWidth;
|
|
||||||
uint16_t m_defaultHeight;
|
|
||||||
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
|
|
||||||
bool m_hasChapters;
|
|
||||||
XtcError m_lastError;
|
|
||||||
|
|
||||||
// Internal helper functions
|
|
||||||
XtcError readHeader();
|
|
||||||
XtcError readPageTable();
|
|
||||||
XtcError readTitle();
|
|
||||||
XtcError readChapters();
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace xtc
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
/**
|
|
||||||
* XtcTypes.h
|
|
||||||
*
|
|
||||||
* XTC file format type definitions
|
|
||||||
* XTC ebook support for CrossPoint Reader
|
|
||||||
*
|
|
||||||
* XTC is the native binary ebook format for XTeink X4 e-reader.
|
|
||||||
* It stores pre-rendered bitmap images per page.
|
|
||||||
*
|
|
||||||
* Format based on EPUB2XTC converter by Rafal-P-Mazur
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace xtc {
|
|
||||||
|
|
||||||
// XTC file magic numbers (little-endian)
|
|
||||||
// "XTC\0" = 0x58, 0x54, 0x43, 0x00
|
|
||||||
constexpr uint32_t XTC_MAGIC = 0x00435458; // "XTC\0" in little-endian (1-bit fast mode)
|
|
||||||
// "XTCH" = 0x58, 0x54, 0x43, 0x48
|
|
||||||
constexpr uint32_t XTCH_MAGIC = 0x48435458; // "XTCH" in little-endian (2-bit high quality mode)
|
|
||||||
// "XTG\0" = 0x58, 0x54, 0x47, 0x00
|
|
||||||
constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for 1-bit page data
|
|
||||||
// "XTH\0" = 0x58, 0x54, 0x48, 0x00
|
|
||||||
constexpr uint32_t XTH_MAGIC = 0x00485458; // "XTH\0" for 2-bit page data
|
|
||||||
|
|
||||||
// XTeink X4 display resolution
|
|
||||||
constexpr uint16_t DISPLAY_WIDTH = 480;
|
|
||||||
constexpr uint16_t DISPLAY_HEIGHT = 800;
|
|
||||||
|
|
||||||
// XTC file header (56 bytes)
|
|
||||||
#pragma pack(push, 1)
|
|
||||||
struct XtcHeader {
|
|
||||||
uint32_t magic; // 0x00: Magic number "XTC\0" (0x00435458)
|
|
||||||
uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0)
|
|
||||||
uint8_t versionMinor; // 0x05: Format version minor (typically 0)
|
|
||||||
uint16_t pageCount; // 0x06: Total page count
|
|
||||||
uint32_t flags; // 0x08: Flags/reserved
|
|
||||||
uint32_t headerSize; // 0x0C: Size of header section (typically 88)
|
|
||||||
uint32_t reserved1; // 0x10: Reserved
|
|
||||||
uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8!
|
|
||||||
uint64_t pageTableOffset; // 0x18: Page table offset
|
|
||||||
uint64_t dataOffset; // 0x20: First page data offset
|
|
||||||
uint64_t reserved2; // 0x28: Reserved
|
|
||||||
uint32_t titleOffset; // 0x30: Title string offset
|
|
||||||
uint32_t padding; // 0x34: Padding to 56 bytes
|
|
||||||
};
|
|
||||||
#pragma pack(pop)
|
|
||||||
|
|
||||||
// Page table entry (16 bytes per page)
|
|
||||||
#pragma pack(push, 1)
|
|
||||||
struct PageTableEntry {
|
|
||||||
uint64_t dataOffset; // 0x00: Absolute offset to page data
|
|
||||||
uint32_t dataSize; // 0x08: Page data size in bytes
|
|
||||||
uint16_t width; // 0x0C: Page width (480)
|
|
||||||
uint16_t height; // 0x0E: Page height (800)
|
|
||||||
};
|
|
||||||
#pragma pack(pop)
|
|
||||||
|
|
||||||
// XTG/XTH page data header (22 bytes)
|
|
||||||
// Used for both 1-bit (XTG) and 2-bit (XTH) formats
|
|
||||||
#pragma pack(push, 1)
|
|
||||||
struct XtgPageHeader {
|
|
||||||
uint32_t magic; // 0x00: File identifier (XTG: 0x00475458, XTH: 0x00485458)
|
|
||||||
uint16_t width; // 0x04: Image width (pixels)
|
|
||||||
uint16_t height; // 0x06: Image height (pixels)
|
|
||||||
uint8_t colorMode; // 0x08: Color mode (0=monochrome)
|
|
||||||
uint8_t compression; // 0x09: Compression (0=uncompressed)
|
|
||||||
uint32_t dataSize; // 0x0A: Image data size (bytes)
|
|
||||||
uint64_t md5; // 0x0E: MD5 checksum (first 8 bytes, optional)
|
|
||||||
// Followed by bitmap data at offset 0x16 (22)
|
|
||||||
//
|
|
||||||
// XTG (1-bit): Row-major, 8 pixels/byte, MSB first
|
|
||||||
// dataSize = ((width + 7) / 8) * height
|
|
||||||
//
|
|
||||||
// XTH (2-bit): Two bit planes, column-major (right-to-left), 8 vertical pixels/byte
|
|
||||||
// dataSize = ((width * height + 7) / 8) * 2
|
|
||||||
// First plane: Bit1 for all pixels
|
|
||||||
// Second plane: Bit2 for all pixels
|
|
||||||
// pixelValue = (bit1 << 1) | bit2
|
|
||||||
};
|
|
||||||
#pragma pack(pop)
|
|
||||||
|
|
||||||
// Page information (internal use, optimized for memory)
|
|
||||||
struct PageInfo {
|
|
||||||
uint32_t offset; // File offset to page data (max 4GB file size)
|
|
||||||
uint32_t size; // Data size (bytes)
|
|
||||||
uint16_t width; // Page width
|
|
||||||
uint16_t height; // Page height
|
|
||||||
uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale)
|
|
||||||
uint8_t padding; // Alignment padding
|
|
||||||
}; // 16 bytes total
|
|
||||||
|
|
||||||
struct ChapterInfo {
|
|
||||||
std::string name;
|
|
||||||
uint16_t startPage;
|
|
||||||
uint16_t endPage;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Error codes
|
|
||||||
enum class XtcError {
|
|
||||||
OK = 0,
|
|
||||||
FILE_NOT_FOUND,
|
|
||||||
INVALID_MAGIC,
|
|
||||||
INVALID_VERSION,
|
|
||||||
CORRUPTED_HEADER,
|
|
||||||
PAGE_OUT_OF_RANGE,
|
|
||||||
READ_ERROR,
|
|
||||||
WRITE_ERROR,
|
|
||||||
MEMORY_ERROR,
|
|
||||||
DECOMPRESSION_ERROR,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert error code to string
|
|
||||||
inline const char* errorToString(XtcError err) {
|
|
||||||
switch (err) {
|
|
||||||
case XtcError::OK:
|
|
||||||
return "OK";
|
|
||||||
case XtcError::FILE_NOT_FOUND:
|
|
||||||
return "File not found";
|
|
||||||
case XtcError::INVALID_MAGIC:
|
|
||||||
return "Invalid magic number";
|
|
||||||
case XtcError::INVALID_VERSION:
|
|
||||||
return "Unsupported version";
|
|
||||||
case XtcError::CORRUPTED_HEADER:
|
|
||||||
return "Corrupted header";
|
|
||||||
case XtcError::PAGE_OUT_OF_RANGE:
|
|
||||||
return "Page out of range";
|
|
||||||
case XtcError::READ_ERROR:
|
|
||||||
return "Read error";
|
|
||||||
case XtcError::WRITE_ERROR:
|
|
||||||
return "Write error";
|
|
||||||
case XtcError::MEMORY_ERROR:
|
|
||||||
return "Memory allocation error";
|
|
||||||
case XtcError::DECOMPRESSION_ERROR:
|
|
||||||
return "Decompression error";
|
|
||||||
default:
|
|
||||||
return "Unknown error";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if filename has XTC/XTCH extension
|
|
||||||
*/
|
|
||||||
inline bool isXtcExtension(const char* filename) {
|
|
||||||
if (!filename) return false;
|
|
||||||
const char* ext = strrchr(filename, '.');
|
|
||||||
if (!ext) return false;
|
|
||||||
return (strcasecmp(ext, ".xtc") == 0 || strcasecmp(ext, ".xtch") == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace xtc
|
|
||||||
@ -12,7 +12,7 @@ namespace {
|
|||||||
constexpr const char* LOG_TAG = "BM";
|
constexpr const char* LOG_TAG = "BM";
|
||||||
|
|
||||||
// Supported book extensions
|
// Supported book extensions
|
||||||
const char* SUPPORTED_EXTENSIONS[] = {".epub", ".txt", ".xtc", ".xtch"};
|
const char* SUPPORTED_EXTENSIONS[] = {".epub", ".txt"};
|
||||||
constexpr size_t SUPPORTED_EXTENSIONS_COUNT = sizeof(SUPPORTED_EXTENSIONS) / sizeof(SUPPORTED_EXTENSIONS[0]);
|
constexpr size_t SUPPORTED_EXTENSIONS_COUNT = sizeof(SUPPORTED_EXTENSIONS) / sizeof(SUPPORTED_EXTENSIONS[0]);
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@ -45,8 +45,6 @@ std::string BookManager::getCachePrefix(const std::string& path) {
|
|||||||
return "epub_";
|
return "epub_";
|
||||||
} else if (ext == ".txt") {
|
} else if (ext == ".txt") {
|
||||||
return "txt_";
|
return "txt_";
|
||||||
} else if (ext == ".xtc" || ext == ".xtch") {
|
|
||||||
return "xtc_";
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ class BookManager {
|
|||||||
// Compute the hash used for cache directory naming
|
// Compute the hash used for cache directory naming
|
||||||
static size_t computePathHash(const std::string& path);
|
static size_t computePathHash(const std::string& path);
|
||||||
|
|
||||||
// Get cache directory prefix for a file type (epub_, txt_, xtc_)
|
// Get cache directory prefix for a file type (epub_, txt_)
|
||||||
static std::string getCachePrefix(const std::string& path);
|
static std::string getCachePrefix(const std::string& path);
|
||||||
|
|
||||||
// Write the .meta file for an archived book
|
// Write the .meta file for an archived book
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@ -8,6 +10,16 @@
|
|||||||
class MappedInputManager;
|
class MappedInputManager;
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
|
|
||||||
|
// Helper macro to log stack high-water mark for a task
|
||||||
|
// Usage: LOG_STACK_WATERMARK("ActivityName", taskHandle);
|
||||||
|
#define LOG_STACK_WATERMARK(name, handle) \
|
||||||
|
do { \
|
||||||
|
if (handle) { \
|
||||||
|
UBaseType_t remaining = uxTaskGetStackHighWaterMark(handle); \
|
||||||
|
Serial.printf("[%lu] [STACK] %s: %u bytes remaining\n", millis(), name, remaining * sizeof(StackType_t)); \
|
||||||
|
} \
|
||||||
|
} while(0)
|
||||||
|
|
||||||
class Activity {
|
class Activity {
|
||||||
protected:
|
protected:
|
||||||
std::string name;
|
std::string name;
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <Txt.h>
|
#include <Txt.h>
|
||||||
#include <Xtc.h>
|
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
@ -323,23 +322,8 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
|
|
||||||
std::string coverBmpPath;
|
std::string coverBmpPath;
|
||||||
|
|
||||||
// Check if the current book is XTC, TXT, or EPUB
|
// Check if the current book is TXT or EPUB
|
||||||
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
|
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
|
||||||
// Handle XTC file
|
|
||||||
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
|
||||||
if (!lastXtc.load()) {
|
|
||||||
Serial.println("[SLP] Failed to load last XTC");
|
|
||||||
return renderDefaultSleepScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastXtc.generateCoverBmp()) {
|
|
||||||
Serial.println("[SLP] Failed to generate XTC cover bmp");
|
|
||||||
return renderDefaultSleepScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
|
||||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
|
||||||
// Handle TXT file - looks for cover image in the same folder
|
// Handle TXT file - looks for cover image in the same folder
|
||||||
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
if (!lastTxt.load()) {
|
if (!lastTxt.load()) {
|
||||||
@ -517,7 +501,7 @@ std::string SleepActivity::getCoverBmpPath(const std::string& cacheDir, const st
|
|||||||
return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp");
|
return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp");
|
||||||
}
|
}
|
||||||
|
|
||||||
// XTC and TXT use a single cover.bmp
|
// TXT uses a single cover.bmp
|
||||||
return cacheDir + "/cover.bmp";
|
return cacheDir + "/cover.bmp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <Xtc.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@ -100,34 +99,6 @@ void HomeActivity::onEnter() {
|
|||||||
coverBmpPath = epub.getThumbBmpPath();
|
coverBmpPath = epub.getThumbBmpPath();
|
||||||
hasCoverImage = true;
|
hasCoverImage = true;
|
||||||
}
|
}
|
||||||
} else if (StringUtils::checkFileExtension(filenameFromPath, ".xtch") ||
|
|
||||||
StringUtils::checkFileExtension(filenameFromPath, ".xtc")) {
|
|
||||||
// Handle XTC file
|
|
||||||
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
|
|
||||||
if (!hasCachedMetadata) {
|
|
||||||
if (xtc.load()) {
|
|
||||||
if (!xtc.getTitle().empty()) {
|
|
||||||
lastBookTitle = std::string(xtc.getTitle());
|
|
||||||
APP_STATE.openBookTitle = lastBookTitle;
|
|
||||||
APP_STATE.saveToFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove extension from title if we don't have metadata
|
|
||||||
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
|
|
||||||
lastBookTitle.resize(lastBookTitle.length() - 5);
|
|
||||||
APP_STATE.openBookTitle = lastBookTitle;
|
|
||||||
APP_STATE.saveToFile();
|
|
||||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
|
|
||||||
lastBookTitle.resize(lastBookTitle.length() - 4);
|
|
||||||
APP_STATE.openBookTitle = lastBookTitle;
|
|
||||||
APP_STATE.saveToFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try to generate thumbnail image for Continue Reading card
|
|
||||||
if (xtc.generateThumbBmp()) {
|
|
||||||
coverBmpPath = xtc.getThumbBmpPath();
|
|
||||||
hasCoverImage = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cached cover buffer is still valid (same book)
|
// Check if cached cover buffer is still valid (same book)
|
||||||
@ -163,6 +134,8 @@ void HomeActivity::onExit() {
|
|||||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
|
// Log stack high-water mark before deleting task (stack size: 4096 bytes)
|
||||||
|
LOG_STACK_WATERMARK("HomeActivity", displayTaskHandle);
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
displayTaskHandle = nullptr;
|
displayTaskHandle = nullptr;
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||||
@ -183,13 +156,18 @@ bool HomeActivity::storeCoverBuffer() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free any existing buffer first
|
|
||||||
freeCoverBuffer();
|
|
||||||
|
|
||||||
const size_t bufferSize = GfxRenderer::getBufferSize();
|
const size_t bufferSize = GfxRenderer::getBufferSize();
|
||||||
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
|
|
||||||
|
// Reuse existing buffer if already allocated (avoids fragmentation from free+malloc)
|
||||||
if (!coverBuffer) {
|
if (!coverBuffer) {
|
||||||
return false;
|
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||||
|
if (!coverBuffer) {
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Failed to allocate cover buffer (%d bytes)\n", millis(),
|
||||||
|
static_cast<int>(bufferSize));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Allocated cover buffer (%d bytes), heap: %d\n", millis(),
|
||||||
|
static_cast<int>(bufferSize), ESP.getFreeHeap());
|
||||||
}
|
}
|
||||||
|
|
||||||
memcpy(coverBuffer, frameBuffer, bufferSize);
|
memcpy(coverBuffer, frameBuffer, bufferSize);
|
||||||
@ -222,6 +200,81 @@ void HomeActivity::freeCoverBuffer() {
|
|||||||
coverBufferStored = false;
|
coverBufferStored = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void HomeActivity::freeCoverBufferIfAllocated() {
|
||||||
|
if (coverBuffer) {
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Freeing cover buffer for reader entry (%d bytes), heap before: %d\n", millis(),
|
||||||
|
static_cast<int>(GfxRenderer::getBufferSize()), ESP.getFreeHeap());
|
||||||
|
free(coverBuffer);
|
||||||
|
coverBuffer = nullptr;
|
||||||
|
coverBufferStored = false;
|
||||||
|
coverRendered = false; // Reset so cover will be reloaded from disk on next Home visit
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Cover buffer freed, heap after: %d\n", millis(), ESP.getFreeHeap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HomeActivity::preloadCoverBuffer() {
|
||||||
|
// If already cached and valid, nothing to do
|
||||||
|
if (coverBufferStored && coverRendered) {
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Cover buffer already preloaded\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a book to continue reading
|
||||||
|
if (APP_STATE.openEpubPath.empty() || !SdMan.exists(APP_STATE.openEpubPath.c_str())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the thumb BMP path based on file type
|
||||||
|
std::string thumbPath;
|
||||||
|
std::string filenameFromPath = APP_STATE.openEpubPath;
|
||||||
|
const size_t lastSlash = filenameFromPath.find_last_of('/');
|
||||||
|
if (lastSlash != std::string::npos) {
|
||||||
|
filenameFromPath = filenameFromPath.substr(lastSlash + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils::checkFileExtension(filenameFromPath, ".epub")) {
|
||||||
|
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
|
if (epub.generateThumbBmp()) {
|
||||||
|
thumbPath = epub.getThumbBmpPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TXT files don't have cover thumbnails, so we skip them
|
||||||
|
|
||||||
|
if (thumbPath.empty() || !SdMan.exists(thumbPath.c_str())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the same cover we already have cached
|
||||||
|
if (coverBufferStored && cachedCoverPath == thumbPath) {
|
||||||
|
coverRendered = true;
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Cover buffer already cached for this book\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate the cover buffer while we have memory headroom
|
||||||
|
// This reduces fragmentation risk when HomeActivity actually renders the cover
|
||||||
|
const size_t bufferSize = GfxRenderer::getBufferSize();
|
||||||
|
if (!coverBuffer) {
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Pre-allocating cover buffer (%d bytes), heap before: %d\n", millis(),
|
||||||
|
static_cast<int>(bufferSize), ESP.getFreeHeap());
|
||||||
|
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||||
|
if (!coverBuffer) {
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Failed to pre-allocate cover buffer\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Cover buffer pre-allocated, heap after: %d\n", millis(), ESP.getFreeHeap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the expected cover path - HomeActivity::onEnter will detect this
|
||||||
|
// and know the buffer is already allocated for this book
|
||||||
|
cachedCoverPath = thumbPath;
|
||||||
|
coverBufferStored = false; // Will be set true after actual render in HomeActivity
|
||||||
|
coverRendered = false; // Will trigger load from disk in render()
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [HOME] [MEM] Cover buffer pre-allocated for: %s\n", millis(), thumbPath.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void HomeActivity::loop() {
|
void HomeActivity::loop() {
|
||||||
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||||
mappedInput.wasPressed(MappedInputManager::Button::Left);
|
mappedInput.wasPressed(MappedInputManager::Button::Left);
|
||||||
@ -279,6 +332,12 @@ void HomeActivity::render() {
|
|||||||
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||||
if (!bufferRestored) {
|
if (!bufferRestored) {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
// If we expected to restore but failed, reset coverRendered so we reload from disk
|
||||||
|
if (coverBufferStored && coverRendered) {
|
||||||
|
Serial.printf("[%lu] [HOME] Buffer restore failed, will reload cover from disk\n", millis());
|
||||||
|
coverRendered = false;
|
||||||
|
coverBufferStored = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
|||||||
@ -41,6 +41,12 @@ class HomeActivity final : public Activity {
|
|||||||
void freeCoverBuffer(); // Free the stored cover buffer
|
void freeCoverBuffer(); // Free the stored cover buffer
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
// Free cover buffer from external activities (e.g., when entering reader to reclaim memory)
|
||||||
|
static void freeCoverBufferIfAllocated();
|
||||||
|
|
||||||
|
// Preload cover buffer from external activities (e.g., MyLibraryActivity) for instant Home screen
|
||||||
|
// Returns true if cover was successfully preloaded or already cached
|
||||||
|
static bool preloadCoverBuffer();
|
||||||
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::function<void()>& onContinueReading, const std::function<void()>& onListsOpen,
|
const std::function<void()>& onContinueReading, const std::function<void()>& onListsOpen,
|
||||||
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onSettingsOpen,
|
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onSettingsOpen,
|
||||||
|
|||||||
@ -152,6 +152,8 @@ void CrossPointWebServerActivity::onExit() {
|
|||||||
// Delete the display task
|
// Delete the display task
|
||||||
Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis());
|
Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis());
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
|
// Log stack high-water mark before deleting task (stack size: 6144 bytes)
|
||||||
|
LOG_STACK_WATERMARK("CrossPointWebServerActivity", displayTaskHandle);
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
displayTaskHandle = nullptr;
|
displayTaskHandle = nullptr;
|
||||||
// Allow idle task to free the task stack
|
// Allow idle task to free the task stack
|
||||||
|
|||||||
@ -190,6 +190,8 @@ void EpubReaderActivity::onExit() {
|
|||||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
|
// Log stack high-water mark before deleting task (stack size: 8192 bytes)
|
||||||
|
LOG_STACK_WATERMARK("EpubReaderActivity", displayTaskHandle);
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
displayTaskHandle = nullptr;
|
displayTaskHandle = nullptr;
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||||
@ -639,30 +641,33 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save bw buffer to reset buffer state after grayscale data sync
|
// grayscale rendering requires storing the BW buffer first
|
||||||
renderer.storeBwBuffer();
|
// If we can't allocate memory for the backup, skip grayscale to avoid artifacts
|
||||||
|
|
||||||
// grayscale rendering
|
|
||||||
// TODO: Only do this if font supports it
|
// TODO: Only do this if font supports it
|
||||||
if (SETTINGS.textAntiAliasing) {
|
if (SETTINGS.textAntiAliasing) {
|
||||||
renderer.clearScreen(0x00);
|
// Try to save BW buffer - if this fails, skip grayscale rendering entirely
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
const bool bwBufferStored = renderer.storeBwBuffer();
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
|
||||||
renderer.copyGrayscaleLsbBuffers();
|
|
||||||
|
|
||||||
// Render and copy to MSB buffer
|
if (bwBufferStored) {
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
renderer.copyGrayscaleMsbBuffers();
|
renderer.copyGrayscaleLsbBuffers();
|
||||||
|
|
||||||
// display grayscale part
|
// Render and copy to MSB buffer
|
||||||
renderer.displayGrayBuffer();
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::BW);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||||
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
renderer.copyGrayscaleMsbBuffers();
|
||||||
|
|
||||||
|
// display grayscale part
|
||||||
|
renderer.displayGrayBuffer();
|
||||||
|
renderer.setRenderMode(GfxRenderer::BW);
|
||||||
|
|
||||||
|
// restore the bw data
|
||||||
|
renderer.restoreBwBuffer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// restore the bw data
|
|
||||||
renderer.restoreBwBuffer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
|
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
|
|||||||
@ -4,8 +4,7 @@
|
|||||||
#include "EpubReaderActivity.h"
|
#include "EpubReaderActivity.h"
|
||||||
#include "Txt.h"
|
#include "Txt.h"
|
||||||
#include "TxtReaderActivity.h"
|
#include "TxtReaderActivity.h"
|
||||||
#include "Xtc.h"
|
#include "activities/home/HomeActivity.h"
|
||||||
#include "XtcReaderActivity.h"
|
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
@ -17,10 +16,6 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
|||||||
return filePath.substr(0, lastSlash);
|
return filePath.substr(0, lastSlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ReaderActivity::isXtcFile(const std::string& path) {
|
|
||||||
return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch");
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ReaderActivity::isTxtFile(const std::string& path) {
|
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||||
return StringUtils::checkFileExtension(path, ".txt") ||
|
return StringUtils::checkFileExtension(path, ".txt") ||
|
||||||
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
|
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
|
||||||
@ -41,21 +36,6 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
|
||||||
if (!SdMan.exists(path.c_str())) {
|
|
||||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
|
|
||||||
if (xtc->load()) {
|
|
||||||
return xtc;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis());
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||||
if (!SdMan.exists(path.c_str())) {
|
if (!SdMan.exists(path.c_str())) {
|
||||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
@ -85,14 +65,6 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
|||||||
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); }));
|
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
|
||||||
const auto xtcPath = xtc->getPath();
|
|
||||||
currentBookPath = xtcPath;
|
|
||||||
exitActivity();
|
|
||||||
enterNewActivity(new XtcReaderActivity(
|
|
||||||
renderer, mappedInput, std::move(xtc), [this, xtcPath] { goToLibrary(xtcPath); }, [this] { onGoBack(); }));
|
|
||||||
}
|
|
||||||
|
|
||||||
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||||
const auto txtPath = txt->getPath();
|
const auto txtPath = txt->getPath();
|
||||||
currentBookPath = txtPath;
|
currentBookPath = txtPath;
|
||||||
@ -104,6 +76,10 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
|||||||
void ReaderActivity::onEnter() {
|
void ReaderActivity::onEnter() {
|
||||||
ActivityWithSubactivity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
// Free HomeActivity's cover buffer to reclaim ~48KB for reader use
|
||||||
|
// The cover BMP is cached on disk, so it will be reloaded (not regenerated) on return to Home
|
||||||
|
HomeActivity::freeCoverBufferIfAllocated();
|
||||||
|
|
||||||
if (initialBookPath.empty()) {
|
if (initialBookPath.empty()) {
|
||||||
goToLibrary(); // Start from root when entering via Browse
|
goToLibrary(); // Start from root when entering via Browse
|
||||||
return;
|
return;
|
||||||
@ -111,14 +87,7 @@ void ReaderActivity::onEnter() {
|
|||||||
|
|
||||||
currentBookPath = initialBookPath;
|
currentBookPath = initialBookPath;
|
||||||
|
|
||||||
if (isXtcFile(initialBookPath)) {
|
if (isTxtFile(initialBookPath)) {
|
||||||
auto xtc = loadXtc(initialBookPath);
|
|
||||||
if (!xtc) {
|
|
||||||
onGoBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onGoToXtcReader(std::move(xtc));
|
|
||||||
} else if (isTxtFile(initialBookPath)) {
|
|
||||||
auto txt = loadTxt(initialBookPath);
|
auto txt = loadTxt(initialBookPath);
|
||||||
if (!txt) {
|
if (!txt) {
|
||||||
onGoBack();
|
onGoBack();
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
#include "activities/home/MyLibraryActivity.h"
|
#include "activities/home/MyLibraryActivity.h"
|
||||||
|
|
||||||
class Epub;
|
class Epub;
|
||||||
class Xtc;
|
|
||||||
class Txt;
|
class Txt;
|
||||||
|
|
||||||
class ReaderActivity final : public ActivityWithSubactivity {
|
class ReaderActivity final : public ActivityWithSubactivity {
|
||||||
@ -15,15 +14,12 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary;
|
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary;
|
||||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
|
||||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||||
static bool isXtcFile(const std::string& path);
|
|
||||||
static bool isTxtFile(const std::string& path);
|
static bool isTxtFile(const std::string& path);
|
||||||
|
|
||||||
static std::string extractFolderPath(const std::string& filePath);
|
static std::string extractFolderPath(const std::string& filePath);
|
||||||
void goToLibrary(const std::string& fromBookPath = "");
|
void goToLibrary(const std::string& fromBookPath = "");
|
||||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
|
||||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|||||||
@ -131,6 +131,8 @@ void TxtReaderActivity::onExit() {
|
|||||||
// Wait until not rendering to delete task
|
// Wait until not rendering to delete task
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
|
// Log stack high-water mark before deleting task (stack size: 6144 bytes)
|
||||||
|
LOG_STACK_WATERMARK("TxtReaderActivity", displayTaskHandle);
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
displayTaskHandle = nullptr;
|
displayTaskHandle = nullptr;
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||||
|
|||||||
@ -1,519 +0,0 @@
|
|||||||
/**
|
|
||||||
* XtcReaderActivity.cpp
|
|
||||||
*
|
|
||||||
* XTC ebook reader activity implementation
|
|
||||||
* Displays pre-rendered XTC pages on e-ink display
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "XtcReaderActivity.h"
|
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
|
|
||||||
#include "BookManager.h"
|
|
||||||
#include "CrossPointSettings.h"
|
|
||||||
#include "CrossPointState.h"
|
|
||||||
#include "MappedInputManager.h"
|
|
||||||
#include "RecentBooksStore.h"
|
|
||||||
#include "XtcReaderChapterSelectionActivity.h"
|
|
||||||
#include "fontIds.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
constexpr unsigned long skipPageMs = 700;
|
|
||||||
constexpr unsigned long goHomeMs = 1000;
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void XtcReaderActivity::taskTrampoline(void* param) {
|
|
||||||
auto* self = static_cast<XtcReaderActivity*>(param);
|
|
||||||
self->displayTaskLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::onEnter() {
|
|
||||||
ActivityWithSubactivity::onEnter();
|
|
||||||
|
|
||||||
if (!xtc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Save current XTC as last opened book and cache title for home screen
|
|
||||||
APP_STATE.openEpubPath = xtc->getPath();
|
|
||||||
APP_STATE.openBookTitle = xtc->getTitle();
|
|
||||||
APP_STATE.openBookAuthor.clear(); // XTC files don't have author metadata
|
|
||||||
APP_STATE.saveToFile();
|
|
||||||
RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), "");
|
|
||||||
|
|
||||||
// Trigger first update
|
|
||||||
updateRequired = true;
|
|
||||||
|
|
||||||
xTaskCreate(&XtcReaderActivity::taskTrampoline, "XtcReaderActivityTask",
|
|
||||||
4096, // Stack size (smaller than EPUB since no parsing needed)
|
|
||||||
this, // Parameters
|
|
||||||
1, // Priority
|
|
||||||
&displayTaskHandle // Task handle
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::onExit() {
|
|
||||||
ActivityWithSubactivity::onExit();
|
|
||||||
|
|
||||||
// Wait until not rendering to delete task
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
if (displayTaskHandle) {
|
|
||||||
vTaskDelete(displayTaskHandle);
|
|
||||||
displayTaskHandle = nullptr;
|
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
|
||||||
}
|
|
||||||
vSemaphoreDelete(renderingMutex);
|
|
||||||
renderingMutex = nullptr;
|
|
||||||
xtc.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::loop() {
|
|
||||||
// Pass input responsibility to sub activity if exists
|
|
||||||
if (subActivity) {
|
|
||||||
subActivity->loop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle end-of-book prompt
|
|
||||||
if (showingEndOfBookPrompt) {
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
||||||
endOfBookSelection = (endOfBookSelection + 2) % 3;
|
|
||||||
updateRequired = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
|
||||||
endOfBookSelection = (endOfBookSelection + 1) % 3;
|
|
||||||
updateRequired = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
||||||
handleEndOfBookAction();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
||||||
// Go back to last page
|
|
||||||
currentPage = xtc->getPageCount() - 1;
|
|
||||||
showingEndOfBookPrompt = false;
|
|
||||||
updateRequired = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter chapter selection activity
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
||||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
exitActivity();
|
|
||||||
enterNewActivity(new XtcReaderChapterSelectionActivity(
|
|
||||||
this->renderer, this->mappedInput, xtc, currentPage,
|
|
||||||
[this] {
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
},
|
|
||||||
[this](const uint32_t newPage) {
|
|
||||||
currentPage = newPage;
|
|
||||||
exitActivity();
|
|
||||||
updateRequired = true;
|
|
||||||
}));
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Long press BACK (1s+) goes directly to home
|
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
|
||||||
onGoHome();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short press BACK goes to file selection
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
|
||||||
onGoBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
|
||||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
|
||||||
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
|
||||||
|
|
||||||
if (!prevReleased && !nextReleased) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If at end of book prompt position, handle differently
|
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs;
|
|
||||||
const int skipAmount = skipPages ? 10 : 1;
|
|
||||||
|
|
||||||
if (prevReleased) {
|
|
||||||
if (currentPage >= static_cast<uint32_t>(skipAmount)) {
|
|
||||||
currentPage -= skipAmount;
|
|
||||||
} else {
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
updateRequired = true;
|
|
||||||
} else if (nextReleased) {
|
|
||||||
currentPage += skipAmount;
|
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
|
||||||
currentPage = xtc->getPageCount(); // Will trigger end-of-book prompt
|
|
||||||
}
|
|
||||||
updateRequired = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::displayTaskLoop() {
|
|
||||||
while (true) {
|
|
||||||
if (updateRequired) {
|
|
||||||
updateRequired = false;
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
renderScreen();
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
}
|
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::renderScreen() {
|
|
||||||
if (!xtc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounds check - show end-of-book prompt
|
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
|
||||||
showingEndOfBookPrompt = true;
|
|
||||||
renderEndOfBookPrompt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showingEndOfBookPrompt = false;
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
saveProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::renderPage() {
|
|
||||||
const uint16_t pageWidth = xtc->getPageWidth();
|
|
||||||
const uint16_t pageHeight = xtc->getPageHeight();
|
|
||||||
const uint8_t bitDepth = xtc->getBitDepth();
|
|
||||||
|
|
||||||
// Calculate buffer size for one page
|
|
||||||
// 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 pageBufferSize;
|
|
||||||
if (bitDepth == 2) {
|
|
||||||
pageBufferSize = ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2;
|
|
||||||
} else {
|
|
||||||
pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate page buffer
|
|
||||||
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
|
|
||||||
if (!pageBuffer) {
|
|
||||||
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
|
|
||||||
renderer.clearScreen();
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
|
|
||||||
renderer.displayBuffer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load page data
|
|
||||||
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
|
|
||||||
if (bytesRead == 0) {
|
|
||||||
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage);
|
|
||||||
free(pageBuffer);
|
|
||||||
renderer.clearScreen();
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
|
|
||||||
renderer.displayBuffer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear screen first
|
|
||||||
renderer.clearScreen();
|
|
||||||
|
|
||||||
// Copy page bitmap using GfxRenderer's drawPixel
|
|
||||||
// XTC/XTCH pages are pre-rendered with status bar included, so render full page
|
|
||||||
const uint16_t maxSrcY = pageHeight;
|
|
||||||
|
|
||||||
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
|
|
||||||
// - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
|
|
||||||
|
|
||||||
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
|
|
||||||
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
|
||||||
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
|
||||||
const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
|
|
||||||
|
|
||||||
// Lambda to get pixel value at (x, y)
|
|
||||||
auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t {
|
|
||||||
const size_t colIndex = pageWidth - 1 - x;
|
|
||||||
const size_t byteInCol = y / 8;
|
|
||||||
const size_t bitInByte = 7 - (y % 8);
|
|
||||||
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
|
||||||
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
|
||||||
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
|
||||||
return (bit1 << 1) | bit2;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory)
|
|
||||||
// Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame
|
|
||||||
|
|
||||||
// Count pixel distribution for debugging
|
|
||||||
uint32_t pixelCounts[4] = {0, 0, 0, 0};
|
|
||||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
|
||||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
|
||||||
pixelCounts[getPixelValue(x, y)]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(),
|
|
||||||
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]);
|
|
||||||
|
|
||||||
// Pass 1: BW buffer - draw all non-white pixels as black
|
|
||||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
|
||||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
|
||||||
if (getPixelValue(x, y) >= 1) {
|
|
||||||
renderer.drawPixel(x, y, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
|
||||||
} else {
|
|
||||||
renderer.displayBuffer();
|
|
||||||
pagesUntilFullRefresh--;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
|
||||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
|
||||||
renderer.clearScreen(0x00);
|
|
||||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
|
||||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
|
||||||
if (getPixelValue(x, y) == 1) { // Dark grey only
|
|
||||||
renderer.drawPixel(x, y, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderer.copyGrayscaleLsbBuffers();
|
|
||||||
|
|
||||||
// Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2)
|
|
||||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
|
||||||
renderer.clearScreen(0x00);
|
|
||||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
|
||||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
|
||||||
const uint8_t pv = getPixelValue(x, y);
|
|
||||||
if (pv == 1 || pv == 2) { // Dark grey or Light grey
|
|
||||||
renderer.drawPixel(x, y, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderer.copyGrayscaleMsbBuffers();
|
|
||||||
|
|
||||||
// Display grayscale overlay
|
|
||||||
renderer.displayGrayBuffer();
|
|
||||||
|
|
||||||
// Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer)
|
|
||||||
renderer.clearScreen();
|
|
||||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
|
||||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
|
||||||
if (getPixelValue(x, y) >= 1) {
|
|
||||||
renderer.drawPixel(x, y, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup grayscale buffers with current frame buffer
|
|
||||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
|
||||||
|
|
||||||
free(pageBuffer);
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1,
|
|
||||||
xtc->getPageCount());
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// 1-bit mode: 8 pixels per byte, MSB first
|
|
||||||
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
|
|
||||||
|
|
||||||
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
|
|
||||||
const size_t srcRowStart = srcY * srcRowBytes;
|
|
||||||
|
|
||||||
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
|
|
||||||
// Read source pixel (MSB first, bit 7 = leftmost pixel)
|
|
||||||
const size_t srcByte = srcRowStart + srcX / 8;
|
|
||||||
const size_t srcBit = 7 - (srcX % 8);
|
|
||||||
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
|
|
||||||
|
|
||||||
if (isBlack) {
|
|
||||||
renderer.drawPixel(srcX, srcY, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// White pixels are already cleared by clearScreen()
|
|
||||||
|
|
||||||
free(pageBuffer);
|
|
||||||
|
|
||||||
// XTC pages already have status bar pre-rendered, no need to add our own
|
|
||||||
|
|
||||||
// Display with appropriate refresh
|
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
|
||||||
} else {
|
|
||||||
renderer.displayBuffer();
|
|
||||||
pagesUntilFullRefresh--;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(),
|
|
||||||
bitDepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::saveProgress() const {
|
|
||||||
FsFile f;
|
|
||||||
if (SdMan.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
|
||||||
uint8_t data[4];
|
|
||||||
data[0] = currentPage & 0xFF;
|
|
||||||
data[1] = (currentPage >> 8) & 0xFF;
|
|
||||||
data[2] = (currentPage >> 16) & 0xFF;
|
|
||||||
data[3] = (currentPage >> 24) & 0xFF;
|
|
||||||
f.write(data, 4);
|
|
||||||
f.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::loadProgress() {
|
|
||||||
FsFile f;
|
|
||||||
if (SdMan.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
|
||||||
uint8_t data[4];
|
|
||||||
if (f.read(data, 4) == 4) {
|
|
||||||
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
|
|
||||||
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage);
|
|
||||||
|
|
||||||
// Validate page number
|
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::renderEndOfBookPrompt() {
|
|
||||||
const int pageWidth = renderer.getScreenWidth();
|
|
||||||
const int pageHeight = renderer.getScreenHeight();
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
|
||||||
|
|
||||||
// Title
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD);
|
|
||||||
|
|
||||||
// Filename (truncated if needed)
|
|
||||||
std::string filename = xtc->getPath();
|
|
||||||
const size_t lastSlash = filename.find_last_of('/');
|
|
||||||
if (lastSlash != std::string::npos) {
|
|
||||||
filename = filename.substr(lastSlash + 1);
|
|
||||||
}
|
|
||||||
if (filename.length() > 30) {
|
|
||||||
filename = filename.substr(0, 27) + "...";
|
|
||||||
}
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str());
|
|
||||||
|
|
||||||
// Menu options
|
|
||||||
const int menuStartY = pageHeight / 2 - 30;
|
|
||||||
constexpr int menuLineHeight = 45;
|
|
||||||
constexpr int menuItemWidth = 140;
|
|
||||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
|
||||||
|
|
||||||
const char* options[] = {"Archive", "Delete", "Keep"};
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
const int optionY = menuStartY + i * menuLineHeight;
|
|
||||||
if (endOfBookSelection == i) {
|
|
||||||
renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5);
|
|
||||||
}
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button hints
|
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
|
|
||||||
renderer.displayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::handleEndOfBookAction() {
|
|
||||||
const std::string bookPath = xtc->getPath();
|
|
||||||
|
|
||||||
switch (endOfBookSelection) {
|
|
||||||
case 0: // Archive
|
|
||||||
BookManager::archiveBook(bookPath);
|
|
||||||
onGoHome();
|
|
||||||
break;
|
|
||||||
case 1: // Delete
|
|
||||||
BookManager::deleteBook(bookPath);
|
|
||||||
onGoHome();
|
|
||||||
break;
|
|
||||||
case 2: // Keep
|
|
||||||
default:
|
|
||||||
onGoHome();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* XtcReaderActivity.h
|
|
||||||
*
|
|
||||||
* XTC ebook reader activity for CrossPoint Reader
|
|
||||||
* Displays pre-rendered XTC pages on e-ink display
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <Xtc.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/semphr.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
#include "activities/ActivityWithSubactivity.h"
|
|
||||||
|
|
||||||
class XtcReaderActivity final : public ActivityWithSubactivity {
|
|
||||||
std::shared_ptr<Xtc> xtc;
|
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
|
||||||
SemaphoreHandle_t renderingMutex = nullptr;
|
|
||||||
uint32_t currentPage = 0;
|
|
||||||
int pagesUntilFullRefresh = 0;
|
|
||||||
bool updateRequired = false;
|
|
||||||
const std::function<void()> onGoBack;
|
|
||||||
const std::function<void()> onGoHome;
|
|
||||||
|
|
||||||
// End-of-book prompt state
|
|
||||||
bool showingEndOfBookPrompt = false;
|
|
||||||
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep
|
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
|
||||||
[[noreturn]] void displayTaskLoop();
|
|
||||||
void renderScreen();
|
|
||||||
void renderPage();
|
|
||||||
void saveProgress() const;
|
|
||||||
void loadProgress();
|
|
||||||
void renderEndOfBookPrompt();
|
|
||||||
void handleEndOfBookAction();
|
|
||||||
|
|
||||||
public:
|
|
||||||
explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Xtc> xtc,
|
|
||||||
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
|
||||||
: ActivityWithSubactivity("XtcReader", renderer, mappedInput),
|
|
||||||
xtc(std::move(xtc)),
|
|
||||||
onGoBack(onGoBack),
|
|
||||||
onGoHome(onGoHome) {}
|
|
||||||
void onEnter() override;
|
|
||||||
void onExit() override;
|
|
||||||
void loop() override;
|
|
||||||
};
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
#include "XtcReaderChapterSelectionActivity.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
|
||||||
#include "fontIds.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
constexpr int SKIP_PAGE_MS = 700;
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
int XtcReaderChapterSelectionActivity::getPageItems() const {
|
|
||||||
constexpr int startY = 60;
|
|
||||||
constexpr int lineHeight = 30;
|
|
||||||
|
|
||||||
const int screenHeight = renderer.getScreenHeight();
|
|
||||||
const int endY = screenHeight - lineHeight;
|
|
||||||
|
|
||||||
const int availableHeight = endY - startY;
|
|
||||||
int items = availableHeight / lineHeight;
|
|
||||||
if (items < 1) {
|
|
||||||
items = 1;
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const {
|
|
||||||
if (!xtc) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto& chapters = xtc->getChapters();
|
|
||||||
for (size_t i = 0; i < chapters.size(); i++) {
|
|
||||||
if (page >= chapters[i].startPage && page <= chapters[i].endPage) {
|
|
||||||
return static_cast<int>(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) {
|
|
||||||
auto* self = static_cast<XtcReaderChapterSelectionActivity*>(param);
|
|
||||||
self->displayTaskLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::onEnter() {
|
|
||||||
Activity::onEnter();
|
|
||||||
|
|
||||||
if (!xtc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
|
||||||
selectorIndex = findChapterIndexForPage(currentPage);
|
|
||||||
|
|
||||||
updateRequired = true;
|
|
||||||
xTaskCreate(&XtcReaderChapterSelectionActivity::taskTrampoline, "XtcReaderChapterSelectionActivityTask",
|
|
||||||
4096, // Stack size
|
|
||||||
this, // Parameters
|
|
||||||
1, // Priority
|
|
||||||
&displayTaskHandle // Task handle
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::onExit() {
|
|
||||||
Activity::onExit();
|
|
||||||
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
if (displayTaskHandle) {
|
|
||||||
vTaskDelete(displayTaskHandle);
|
|
||||||
displayTaskHandle = nullptr;
|
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
|
||||||
}
|
|
||||||
vSemaphoreDelete(renderingMutex);
|
|
||||||
renderingMutex = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::loop() {
|
|
||||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
|
||||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
|
||||||
|
|
||||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
|
||||||
const int pageItems = getPageItems();
|
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
||||||
const auto& chapters = xtc->getChapters();
|
|
||||||
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
|
|
||||||
onSelectPage(chapters[selectorIndex].startPage);
|
|
||||||
}
|
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
||||||
onGoBack();
|
|
||||||
} else if (prevReleased) {
|
|
||||||
const int total = static_cast<int>(xtc->getChapters().size());
|
|
||||||
if (total == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (skipPage) {
|
|
||||||
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
|
|
||||||
} else {
|
|
||||||
selectorIndex = (selectorIndex + total - 1) % total;
|
|
||||||
}
|
|
||||||
updateRequired = true;
|
|
||||||
} else if (nextReleased) {
|
|
||||||
const int total = static_cast<int>(xtc->getChapters().size());
|
|
||||||
if (total == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (skipPage) {
|
|
||||||
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
|
|
||||||
} else {
|
|
||||||
selectorIndex = (selectorIndex + 1) % total;
|
|
||||||
}
|
|
||||||
updateRequired = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::displayTaskLoop() {
|
|
||||||
while (true) {
|
|
||||||
if (updateRequired) {
|
|
||||||
updateRequired = false;
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
||||||
renderScreen();
|
|
||||||
xSemaphoreGive(renderingMutex);
|
|
||||||
}
|
|
||||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderChapterSelectionActivity::renderScreen() {
|
|
||||||
renderer.clearScreen();
|
|
||||||
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
|
||||||
const int pageItems = getPageItems();
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, EpdFontFamily::BOLD);
|
|
||||||
|
|
||||||
const auto& chapters = xtc->getChapters();
|
|
||||||
if (chapters.empty()) {
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, "No chapters");
|
|
||||||
renderer.displayBuffer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
||||||
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
|
||||||
for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) {
|
|
||||||
const auto& chapter = chapters[i];
|
|
||||||
const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str();
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
|
|
||||||
renderer.displayBuffer();
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <Xtc.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/semphr.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
#include "../Activity.h"
|
|
||||||
|
|
||||||
class XtcReaderChapterSelectionActivity final : public Activity {
|
|
||||||
std::shared_ptr<Xtc> xtc;
|
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
|
||||||
SemaphoreHandle_t renderingMutex = nullptr;
|
|
||||||
uint32_t currentPage = 0;
|
|
||||||
int selectorIndex = 0;
|
|
||||||
bool updateRequired = false;
|
|
||||||
const std::function<void()> onGoBack;
|
|
||||||
const std::function<void(uint32_t newPage)> onSelectPage;
|
|
||||||
|
|
||||||
int getPageItems() const;
|
|
||||||
int findChapterIndexForPage(uint32_t page) const;
|
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
|
||||||
[[noreturn]] void displayTaskLoop();
|
|
||||||
void renderScreen();
|
|
||||||
|
|
||||||
public:
|
|
||||||
explicit XtcReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
|
||||||
const std::shared_ptr<Xtc>& xtc, uint32_t currentPage,
|
|
||||||
const std::function<void()>& onGoBack,
|
|
||||||
const std::function<void(uint32_t newPage)>& onSelectPage)
|
|
||||||
: Activity("XtcReaderChapterSelection", renderer, mappedInput),
|
|
||||||
xtc(xtc),
|
|
||||||
currentPage(currentPage),
|
|
||||||
onGoBack(onGoBack),
|
|
||||||
onSelectPage(onSelectPage) {}
|
|
||||||
void onEnter() override;
|
|
||||||
void onExit() override;
|
|
||||||
void loop() override;
|
|
||||||
};
|
|
||||||
@ -5,6 +5,8 @@
|
|||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/home/HomeActivity.h"
|
||||||
|
#include "activities/home/MyLibraryActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
void ClearCacheActivity::taskTrampoline(void* param) {
|
void ClearCacheActivity::taskTrampoline(void* param) {
|
||||||
@ -125,8 +127,8 @@ void ClearCacheActivity::clearCache() {
|
|||||||
file.getName(name, sizeof(name));
|
file.getName(name, sizeof(name));
|
||||||
String itemName(name);
|
String itemName(name);
|
||||||
|
|
||||||
// Only delete directories starting with epub_ or xtc_
|
// Only delete directories starting with epub_ or txt_
|
||||||
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
|
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
|
||||||
String fullPath = "/.crosspoint/" + itemName;
|
String fullPath = "/.crosspoint/" + itemName;
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
|
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
|
||||||
|
|
||||||
@ -144,6 +146,10 @@ void ClearCacheActivity::clearCache() {
|
|||||||
}
|
}
|
||||||
root.close();
|
root.close();
|
||||||
|
|
||||||
|
// Also clear in-memory caches since disk cache is gone
|
||||||
|
HomeActivity::freeCoverBufferIfAllocated();
|
||||||
|
MyLibraryActivity::clearThumbExistsCache();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
|
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
|
||||||
|
|
||||||
state = SUCCESS;
|
state = SUCCESS;
|
||||||
|
|||||||
@ -519,8 +519,17 @@ void loop() {
|
|||||||
inputManager.update();
|
inputManager.update();
|
||||||
|
|
||||||
if (Serial && millis() - lastMemPrint >= 10000) {
|
if (Serial && millis() - lastMemPrint >= 10000) {
|
||||||
|
// Basic heap info
|
||||||
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
|
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
|
||||||
ESP.getHeapSize(), ESP.getMinFreeHeap());
|
ESP.getHeapSize(), ESP.getMinFreeHeap());
|
||||||
|
|
||||||
|
// Detailed fragmentation info using ESP-IDF heap caps API
|
||||||
|
multi_heap_info_t info;
|
||||||
|
heap_caps_get_info(&info, MALLOC_CAP_8BIT);
|
||||||
|
Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(),
|
||||||
|
info.largest_free_block, info.total_allocated_bytes,
|
||||||
|
info.allocated_blocks, info.free_blocks);
|
||||||
|
|
||||||
lastMemPrint = millis();
|
lastMemPrint = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
namespace {
|
namespace {
|
||||||
// Folders/files to hide from the web interface file browser
|
// Folders/files to hide from the web interface file browser
|
||||||
// Note: Items starting with "." are automatically hidden
|
// Note: Items starting with "." are automatically hidden
|
||||||
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
const char* HIDDEN_ITEMS[] = {"System Volume Information"};
|
||||||
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
||||||
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
|
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
|
||||||
constexpr uint16_t LOCAL_UDP_PORT = 8134;
|
constexpr uint16_t LOCAL_UDP_PORT = 8134;
|
||||||
|
|||||||
@ -1156,7 +1156,7 @@
|
|||||||
// Check if file is a book format (can be archived)
|
// Check if file is a book format (can be archived)
|
||||||
function isBookFormat(filename) {
|
function isBookFormat(filename) {
|
||||||
const ext = filename.toLowerCase();
|
const ext = filename.toLowerCase();
|
||||||
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
|
return ext.endsWith('.epub') || ext.endsWith('.txt');
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedFiles.forEach((file, index) => {
|
sortedFiles.forEach((file, index) => {
|
||||||
|
|||||||
@ -42,8 +42,6 @@ std::string getCacheDirForBook(const std::string& bookPath, const std::string& c
|
|||||||
prefix = "epub_";
|
prefix = "epub_";
|
||||||
} else if (ext == ".txt") {
|
} else if (ext == ".txt") {
|
||||||
prefix = "txt_";
|
prefix = "txt_";
|
||||||
} else if (ext == ".xtc" || ext == ".xtch") {
|
|
||||||
prefix = "xtc_";
|
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user