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:
cottongin 2026-01-27 20:35:32 -05:00
parent 158caacfe0
commit c2a966a6ea
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
30 changed files with 201 additions and 2624 deletions

View File

@ -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

View File

@ -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;
} }

View File

@ -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
*/ */

View File

@ -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;
} }

View File

@ -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>

View File

@ -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();
}

View File

@ -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;
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 "";
} }

View File

@ -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

View File

@ -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;

View File

@ -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";
} }

View File

@ -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();

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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();

View File

@ -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:

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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();
}

View File

@ -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;
};

View File

@ -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;

View File

@ -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();
} }

View File

@ -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;

View File

@ -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) => {

View File

@ -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 "";
} }