diff --git a/CrossPoint-ef.md b/CrossPoint-ef.md index 53a190f..b155a53 100644 --- a/CrossPoint-ef.md +++ b/CrossPoint-ef.md @@ -1,12 +1,7 @@ ## Feature Requests: 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 - - 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). +2) Bookmarks 3) ability to add/remove books from lists on device. 4) quick menu 5) hide "system folders" from files view diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 8515004..312b8bd 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -251,8 +251,8 @@ bool Epub::parseCssFiles() { SdMan.remove(tmpCssPath.c_str()); } - Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(), - cssFiles.size()); + Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(), cssParser->ruleCount(), + cssFiles.size(), cssParser->estimateMemoryUsage()); return true; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index a180236..d6888d7 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -71,6 +71,25 @@ class CssParser { */ [[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 */ diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 507dbb5..2ce2162 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -857,7 +857,7 @@ bool GfxRenderer::storeBwBuffer() { * Uses chunked restoration to match chunked storage. */ void GfxRenderer::restoreBwBuffer() { - // Check if any all chunks are allocated + // Check if all chunks are allocated bool missingChunks = false; for (const auto& bwBufferChunk : bwBufferChunks) { if (!bwBufferChunk) { @@ -868,6 +868,13 @@ void GfxRenderer::restoreBwBuffer() { if (missingChunks) { 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; } @@ -883,6 +890,8 @@ void GfxRenderer::restoreBwBuffer() { if (!bwBufferChunks[i]) { Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis()); freeBwBufferChunks(); + // CRITICAL: Clean up grayscale state even on mid-restore failure + einkDisplay.cleanupGrayscaleBuffers(frameBuffer); return; } diff --git a/lib/Xtc/README b/lib/Xtc/README deleted file mode 100644 index 1f55eff..0000000 --- a/lib/Xtc/README +++ /dev/null @@ -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: diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp deleted file mode 100644 index d05e3f6..0000000 --- a/lib/Xtc/Xtc.cpp +++ /dev/null @@ -1,880 +0,0 @@ -/** - * Xtc.cpp - * - * Main XTC ebook class implementation - * XTC ebook support for CrossPoint Reader - */ - -#include "Xtc.h" - -#include -#include -#include - -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::getChapters() const { - static const std::vector 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(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; - } - uint8_t* pageBuffer = static_cast(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(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(&fileSize), 4); - uint32_t reserved = 0; - coverBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) - coverBmp.write(reinterpret_cast(&dataOffset), 4); - - // DIB header (BITMAPINFOHEADER - 40 bytes) - uint32_t dibHeaderSize = 40; - coverBmp.write(reinterpret_cast(&dibHeaderSize), 4); - int32_t width = pageInfo.width; - coverBmp.write(reinterpret_cast(&width), 4); - int32_t height = -static_cast(pageInfo.height); // Negative for top-down - coverBmp.write(reinterpret_cast(&height), 4); - uint16_t planes = 1; - coverBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 1; // 1-bit monochrome - coverBmp.write(reinterpret_cast(&bitsPerPixel), 2); - uint32_t compression = 0; // BI_RGB (no compression) - coverBmp.write(reinterpret_cast(&compression), 4); - coverBmp.write(reinterpret_cast(&imageSize), 4); - int32_t ppmX = 2835; // 72 DPI - coverBmp.write(reinterpret_cast(&ppmX), 4); - int32_t ppmY = 2835; - coverBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 2; - coverBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 2; - coverBmp.write(reinterpret_cast(&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(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(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(THUMB_TARGET_WIDTH) / pageInfo.width; - float scaleY = static_cast(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(pageInfo.width * scale); - uint16_t thumbHeight = static_cast(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(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; - } - uint8_t* pageBuffer = static_cast(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(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(&fileSize), 4); - uint32_t reserved = 0; - thumbBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) - thumbBmp.write(reinterpret_cast(&dataOffset), 4); - - // DIB header - uint32_t dibHeaderSize = 40; - thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); - int32_t widthVal = thumbWidth; - thumbBmp.write(reinterpret_cast(&widthVal), 4); - int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down - thumbBmp.write(reinterpret_cast(&heightVal), 4); - uint16_t planes = 1; - thumbBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 1; // 1-bit for black and white - thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); - uint32_t compression = 0; - thumbBmp.write(reinterpret_cast(&compression), 4); - thumbBmp.write(reinterpret_cast(&imageSize), 4); - int32_t ppmX = 2835; - thumbBmp.write(reinterpret_cast(&ppmX), 4); - int32_t ppmY = 2835; - thumbBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 2; - thumbBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 2; - thumbBmp.write(reinterpret_cast(&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(malloc(rowSize)); - if (!rowBuffer) { - free(pageBuffer); - thumbBmp.close(); - return false; - } - - // Fixed-point scale factor (16.16) - uint32_t scaleInv_fp = static_cast(65536.0f / scale); - - // Pre-calculate plane info for 2-bit mode - const size_t planeSize = (bitDepth == 2) ? ((static_cast(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(dstY) * scaleInv_fp) >> 16; - uint32_t srcYEnd = (static_cast(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(dstX) * scaleInv_fp) >> 16; - uint32_t srcXEnd = (static_cast(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(graySum / totalCount) : 255; - - // Hash-based noise dithering for 1-bit output - uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(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(MICRO_THUMB_TARGET_WIDTH) / pageInfo.width; - float scaleY = static_cast(MICRO_THUMB_TARGET_HEIGHT) / pageInfo.height; - float scale = (scaleX < scaleY) ? scaleX : scaleY; - - uint16_t microThumbWidth = static_cast(pageInfo.width * scale); - uint16_t microThumbHeight = static_cast(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(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; - } - uint8_t* pageBuffer = static_cast(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(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(&fileSize), 4); - uint32_t reserved = 0; - microThumbBmp.write(reinterpret_cast(&reserved), 4); - uint32_t dataOffset = 14 + 40 + 8; - microThumbBmp.write(reinterpret_cast(&dataOffset), 4); - - // DIB header - uint32_t dibHeaderSize = 40; - microThumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); - int32_t widthVal = microThumbWidth; - microThumbBmp.write(reinterpret_cast(&widthVal), 4); - int32_t heightVal = -static_cast(microThumbHeight); // Negative for top-down - microThumbBmp.write(reinterpret_cast(&heightVal), 4); - uint16_t planes = 1; - microThumbBmp.write(reinterpret_cast(&planes), 2); - uint16_t bitsPerPixel = 1; - microThumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); - uint32_t compression = 0; - microThumbBmp.write(reinterpret_cast(&compression), 4); - microThumbBmp.write(reinterpret_cast(&imageSize), 4); - int32_t ppmX = 2835; - microThumbBmp.write(reinterpret_cast(&ppmX), 4); - int32_t ppmY = 2835; - microThumbBmp.write(reinterpret_cast(&ppmY), 4); - uint32_t colorsUsed = 2; - microThumbBmp.write(reinterpret_cast(&colorsUsed), 4); - uint32_t colorsImportant = 2; - microThumbBmp.write(reinterpret_cast(&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(malloc(rowSize)); - if (!rowBuffer) { - free(pageBuffer); - microThumbBmp.close(); - return false; - } - - // Fixed-point scale factor (16.16) - uint32_t scaleInv_fp = static_cast(65536.0f / scale); - - // Pre-calculate plane info for 2-bit mode - const size_t planeSize = (bitDepth == 2) ? ((static_cast(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(dstY) * scaleInv_fp) >> 16; - uint32_t srcYEnd = (static_cast(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(dstX) * scaleInv_fp) >> 16; - uint32_t srcXEnd = (static_cast(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(graySum / totalCount) : 255; - - // Hash-based noise dithering - uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(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& 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(parser.get())->loadPage(pageIndex, buffer, bufferSize); -} - -xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex, - std::function callback, - size_t chunkSize) const { - if (!loaded || !parser) { - return xtc::XtcError::FILE_NOT_FOUND; - } - return const_cast(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((currentPage + 1) * 100 / parser->getPageCount()); -} - -xtc::XtcError Xtc::getLastError() const { - if (!parser) { - return xtc::XtcError::FILE_NOT_FOUND; - } - return parser->getLastError(); -} diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h deleted file mode 100644 index 1a2242d..0000000 --- a/lib/Xtc/Xtc.h +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Xtc.h - * - * Main XTC ebook class for CrossPoint Reader - * Provides EPUB-like interface for XTC file handling - */ - -#pragma once - -#include -#include -#include -#include - -#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 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{}(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& 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& 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 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; -}; diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp deleted file mode 100644 index c33e719..0000000 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ /dev/null @@ -1,439 +0,0 @@ -/** - * XtcParser.cpp - * - * XTC file parsing implementation - * XTC ebook support for CrossPoint Reader - */ - -#include "XtcParser.h" - -#include -#include -#include - -#include - -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(&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(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(&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(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(&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(available / chapterSize); - if (chapterCount == 0) { - return XtcError::OK; - } - - if (!m_file.seek(chapterOffset)) { - return XtcError::READ_ERROR; - } - - std::vector 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(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(&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(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 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(&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(pageHeader.width) * pageHeader.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; - } - - // Read in chunks - std::vector 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(&magic), sizeof(magic)); - file.close(); - - if (bytesRead != sizeof(magic)) { - return false; - } - - return (magic == XTC_MAGIC || magic == XTCH_MAGIC); -} - -} // namespace xtc diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h deleted file mode 100644 index 2d2b780..0000000 --- a/lib/Xtc/Xtc/XtcParser.h +++ /dev/null @@ -1,102 +0,0 @@ -/** - * XtcParser.h - * - * XTC file parsing and page data extraction - * XTC ebook support for CrossPoint Reader - */ - -#pragma once - -#include - -#include -#include -#include -#include - -#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 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& 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 m_pageTable; - std::vector 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 diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h deleted file mode 100644 index 08f9c00..0000000 --- a/lib/Xtc/Xtc/XtcTypes.h +++ /dev/null @@ -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 -#include - -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 diff --git a/src/BookManager.cpp b/src/BookManager.cpp index 326dc8c..f93153f 100644 --- a/src/BookManager.cpp +++ b/src/BookManager.cpp @@ -12,7 +12,7 @@ namespace { constexpr const char* LOG_TAG = "BM"; // 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]); } // namespace @@ -45,8 +45,6 @@ std::string BookManager::getCachePrefix(const std::string& path) { return "epub_"; } else if (ext == ".txt") { return "txt_"; - } else if (ext == ".xtc" || ext == ".xtch") { - return "xtc_"; } return ""; } diff --git a/src/BookManager.h b/src/BookManager.h index ee54775..4588667 100644 --- a/src/BookManager.h +++ b/src/BookManager.h @@ -67,7 +67,7 @@ class BookManager { // Compute the hash used for cache directory naming 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); // Write the .meta file for an archived book diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 4a60607..7be7350 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include @@ -8,6 +10,16 @@ class MappedInputManager; 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 { protected: std::string name; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index bee3815..b71b9bb 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include @@ -323,23 +322,8 @@ void SleepActivity::renderCoverSleepScreen() const { std::string coverBmpPath; - // Check if the current book is XTC, TXT, or EPUB - if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || - 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")) { + // Check if the current book is TXT or EPUB + if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { // Handle TXT file - looks for cover image in the same folder Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); 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"); } - // XTC and TXT use a single cover.bmp + // TXT uses a single cover.bmp return cacheDir + "/cover.bmp"; } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 96e5ac2..9abf31f 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -100,34 +99,6 @@ void HomeActivity::onEnter() { coverBmpPath = epub.getThumbBmpPath(); 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) @@ -163,6 +134,8 @@ void HomeActivity::onExit() { // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { + // Log stack high-water mark before deleting task (stack size: 4096 bytes) + LOG_STACK_WATERMARK("HomeActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack @@ -183,13 +156,18 @@ bool HomeActivity::storeCoverBuffer() { return false; } - // Free any existing buffer first - freeCoverBuffer(); - const size_t bufferSize = GfxRenderer::getBufferSize(); - coverBuffer = static_cast(malloc(bufferSize)); + + // Reuse existing buffer if already allocated (avoids fragmentation from free+malloc) if (!coverBuffer) { - return false; + coverBuffer = static_cast(malloc(bufferSize)); + if (!coverBuffer) { + Serial.printf("[%lu] [HOME] [MEM] Failed to allocate cover buffer (%d bytes)\n", millis(), + static_cast(bufferSize)); + return false; + } + Serial.printf("[%lu] [HOME] [MEM] Allocated cover buffer (%d bytes), heap: %d\n", millis(), + static_cast(bufferSize), ESP.getFreeHeap()); } memcpy(coverBuffer, frameBuffer, bufferSize); @@ -222,6 +200,81 @@ void HomeActivity::freeCoverBuffer() { 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(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(bufferSize), ESP.getFreeHeap()); + coverBuffer = static_cast(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() { const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left); @@ -279,6 +332,12 @@ void HomeActivity::render() { const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); if (!bufferRestored) { 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(); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index c0807f7..d69c3e6 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -41,6 +41,12 @@ class HomeActivity final : public Activity { void freeCoverBuffer(); // Free the stored cover buffer 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, const std::function& onContinueReading, const std::function& onListsOpen, const std::function& onMyLibraryOpen, const std::function& onSettingsOpen, diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 5b35592..93a8534 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -152,6 +152,8 @@ void CrossPointWebServerActivity::onExit() { // Delete the display task Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis()); if (displayTaskHandle) { + // Log stack high-water mark before deleting task (stack size: 6144 bytes) + LOG_STACK_WATERMARK("CrossPointWebServerActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; // Allow idle task to free the task stack diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 9276652..1082d93 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -190,6 +190,8 @@ void EpubReaderActivity::onExit() { // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { + // Log stack high-water mark before deleting task (stack size: 8192 bytes) + LOG_STACK_WATERMARK("EpubReaderActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack @@ -639,30 +641,33 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or pagesUntilFullRefresh--; } - // Save bw buffer to reset buffer state after grayscale data sync - renderer.storeBwBuffer(); - - // grayscale rendering + // grayscale rendering requires storing the BW buffer first + // If we can't allocate memory for the backup, skip grayscale to avoid artifacts // TODO: Only do this if font supports it if (SETTINGS.textAntiAliasing) { - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleLsbBuffers(); + // Try to save BW buffer - if this fails, skip grayscale rendering entirely + const bool bwBufferStored = renderer.storeBwBuffer(); - // Render and copy to MSB buffer - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleMsbBuffers(); + if (bwBufferStored) { + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderer.copyGrayscaleLsbBuffers(); - // display grayscale part - renderer.displayGrayBuffer(); - renderer.setRenderMode(GfxRenderer::BW); + // Render and copy to MSB buffer + renderer.clearScreen(0x00); + 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, diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 04240b3..667afed 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -4,8 +4,7 @@ #include "EpubReaderActivity.h" #include "Txt.h" #include "TxtReaderActivity.h" -#include "Xtc.h" -#include "XtcReaderActivity.h" +#include "activities/home/HomeActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "util/StringUtils.h" @@ -17,10 +16,6 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { 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) { return StringUtils::checkFileExtension(path, ".txt") || StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader) @@ -41,21 +36,6 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { return nullptr; } -std::unique_ptr 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(new Xtc(path, "/.crosspoint")); - if (xtc->load()) { - return xtc; - } - - Serial.printf("[%lu] [ ] Failed to load XTC\n", millis()); - return nullptr; -} - std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { if (!SdMan.exists(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) { renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); })); } -void ReaderActivity::onGoToXtcReader(std::unique_ptr 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) { const auto txtPath = txt->getPath(); currentBookPath = txtPath; @@ -104,6 +76,10 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { void ReaderActivity::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()) { goToLibrary(); // Start from root when entering via Browse return; @@ -111,14 +87,7 @@ void ReaderActivity::onEnter() { currentBookPath = initialBookPath; - if (isXtcFile(initialBookPath)) { - auto xtc = loadXtc(initialBookPath); - if (!xtc) { - onGoBack(); - return; - } - onGoToXtcReader(std::move(xtc)); - } else if (isTxtFile(initialBookPath)) { + if (isTxtFile(initialBookPath)) { auto txt = loadTxt(initialBookPath); if (!txt) { onGoBack(); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index ab74878..c2753f3 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -5,7 +5,6 @@ #include "activities/home/MyLibraryActivity.h" class Epub; -class Xtc; class Txt; class ReaderActivity final : public ActivityWithSubactivity { @@ -15,15 +14,12 @@ class ReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; const std::function onGoToLibrary; static std::unique_ptr loadEpub(const std::string& path); - static std::unique_ptr loadXtc(const std::string& path); static std::unique_ptr loadTxt(const std::string& path); - static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void goToLibrary(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); - void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); public: diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index cbb69d6..862a0e0 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -131,6 +131,8 @@ void TxtReaderActivity::onExit() { // Wait until not rendering to delete task xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { + // Log stack high-water mark before deleting task (stack size: 6144 bytes) + LOG_STACK_WATERMARK("TxtReaderActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp deleted file mode 100644 index 61b6485..0000000 --- a/src/activities/reader/XtcReaderActivity.cpp +++ /dev/null @@ -1,519 +0,0 @@ -/** - * XtcReaderActivity.cpp - * - * XTC ebook reader activity implementation - * Displays pre-rendered XTC pages on e-ink display - */ - -#include "XtcReaderActivity.h" - -#include -#include -#include - -#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(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(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(pageWidth) * pageHeight + 7) / 8) * 2; - } else { - pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; - } - - // Allocate page buffer - uint8_t* pageBuffer = static_cast(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(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; - } -} diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h deleted file mode 100644 index 44952ab..0000000 --- a/src/activities/reader/XtcReaderActivity.h +++ /dev/null @@ -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 -#include -#include -#include - -#include "activities/ActivityWithSubactivity.h" - -class XtcReaderActivity final : public ActivityWithSubactivity { - std::shared_ptr xtc; - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - uint32_t currentPage = 0; - int pagesUntilFullRefresh = 0; - bool updateRequired = false; - const std::function onGoBack; - const std::function 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, - const std::function& onGoBack, const std::function& onGoHome) - : ActivityWithSubactivity("XtcReader", renderer, mappedInput), - xtc(std::move(xtc)), - onGoBack(onGoBack), - onGoHome(onGoHome) {} - void onEnter() override; - void onExit() override; - void loop() override; -}; diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp deleted file mode 100644 index 50b2019..0000000 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ /dev/null @@ -1,157 +0,0 @@ -#include "XtcReaderChapterSelectionActivity.h" - -#include - -#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(i); - } - } - return 0; -} - -void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) { - auto* self = static_cast(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(chapters.size())) { - onSelectPage(chapters[selectorIndex].startPage); - } - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoBack(); - } else if (prevReleased) { - const int total = static_cast(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(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(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(); -} diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.h b/src/activities/reader/XtcReaderChapterSelectionActivity.h deleted file mode 100644 index f0fe06b..0000000 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once -#include -#include -#include -#include - -#include - -#include "../Activity.h" - -class XtcReaderChapterSelectionActivity final : public Activity { - std::shared_ptr xtc; - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - uint32_t currentPage = 0; - int selectorIndex = 0; - bool updateRequired = false; - const std::function onGoBack; - const std::function 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, uint32_t currentPage, - const std::function& onGoBack, - const std::function& onSelectPage) - : Activity("XtcReaderChapterSelection", renderer, mappedInput), - xtc(xtc), - currentPage(currentPage), - onGoBack(onGoBack), - onSelectPage(onSelectPage) {} - void onEnter() override; - void onExit() override; - void loop() override; -}; diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index 4edcb56..3d2abee 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -5,6 +5,8 @@ #include #include "MappedInputManager.h" +#include "activities/home/HomeActivity.h" +#include "activities/home/MyLibraryActivity.h" #include "fontIds.h" void ClearCacheActivity::taskTrampoline(void* param) { @@ -125,8 +127,8 @@ void ClearCacheActivity::clearCache() { file.getName(name, sizeof(name)); String itemName(name); - // Only delete directories starting with epub_ or xtc_ - if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) { + // Only delete directories starting with epub_ or txt_ + if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) { String fullPath = "/.crosspoint/" + itemName; Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str()); @@ -144,6 +146,10 @@ void ClearCacheActivity::clearCache() { } 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); state = SUCCESS; diff --git a/src/main.cpp b/src/main.cpp index 93f6cf2..b16d0b7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -519,8 +519,17 @@ void loop() { inputManager.update(); 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(), 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(); } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index ca53640..beb74a2 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -20,7 +20,7 @@ namespace { // Folders/files to hide from the web interface file browser // 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 uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; constexpr uint16_t LOCAL_UDP_PORT = 8134; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index a3beec4..6886397 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1156,7 +1156,7 @@ // Check if file is a book format (can be archived) function isBookFormat(filename) { 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) => { diff --git a/src/util/Md5Utils.cpp b/src/util/Md5Utils.cpp index 796f5a2..350474b 100644 --- a/src/util/Md5Utils.cpp +++ b/src/util/Md5Utils.cpp @@ -42,8 +42,6 @@ std::string getCacheDirForBook(const std::string& bookPath, const std::string& c prefix = "epub_"; } else if (ext == ".txt") { prefix = "txt_"; - } else if (ext == ".xtc" || ext == ".xtch") { - prefix = "xtc_"; } else { return ""; }