diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 5b84b502..0fc4abf1 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -341,12 +341,13 @@ void GfxRenderer::freeBwBufferChunks() { * This should be called before grayscale buffers are populated. * A `restoreBwBuffer` call should always follow the grayscale render if this method was called. * Uses chunked allocation to avoid needing 48KB of contiguous memory. + * Returns true if buffer was stored successfully, false if allocation failed. */ -void GfxRenderer::storeBwBuffer() { +bool GfxRenderer::storeBwBuffer() { const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); - return; + return false; } // Allocate and copy each chunk @@ -367,7 +368,7 @@ void GfxRenderer::storeBwBuffer() { BW_BUFFER_CHUNK_SIZE); // Free previously allocated chunks freeBwBufferChunks(); - return; + return false; } memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); @@ -375,6 +376,7 @@ void GfxRenderer::storeBwBuffer() { Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE); + return true; } /** @@ -422,6 +424,17 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } +/** + * Cleanup grayscale buffers using the current frame buffer. + * Use this when BW buffer was re-rendered instead of stored/restored. + */ +void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { + uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (frameBuffer) { + einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + } +} + void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, const bool pixelState, const EpdFontStyle style) const { const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 55d08083..241c76e3 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -85,8 +85,9 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - void storeBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully void restoreBwBuffer(); + void cleanupGrayscaleWithFrameBuffer() const; // Low level functions uint8_t* getFrameBuffer() const; diff --git a/lib/Xtc/README b/lib/Xtc/README new file mode 100644 index 00000000..1f55effa --- /dev/null +++ b/lib/Xtc/README @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 00000000..fe0b107e --- /dev/null +++ b/lib/Xtc/Xtc.cpp @@ -0,0 +1,337 @@ +/** + * 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 (!SD.exists(cachePath.c_str())) { + Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis()); + return true; + } + + if (!FsHelpers::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 (SD.exists(cachePath.c_str())) { + return; + } + + // Create directories recursively + for (size_t i = 1; i < cachePath.length(); i++) { + if (cachePath[i] == '/') { + SD.mkdir(cachePath.substr(0, i).c_str()); + } + } + SD.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); +} + +std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Xtc::generateCoverBmp() const { + // Already generated + if (SD.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 + File coverBmp; + if (!FsHelpers::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 uses inverted polarity: 0 = black, 1 = white + // 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; +} + +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 new file mode 100644 index 00000000..42e05ef3 --- /dev/null +++ b/lib/Xtc/Xtc.h @@ -0,0 +1,97 @@ +/** + * Xtc.h + * + * Main XTC ebook class for CrossPoint Reader + * Provides EPUB-like interface for XTC file handling + */ + +#pragma once + +#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; + + // Cover image support (for sleep screen) + std::string getCoverBmpPath() const; + bool generateCoverBmp() 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 new file mode 100644 index 00000000..a443f57b --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -0,0 +1,316 @@ +/** + * XtcParser.cpp + * + * XTC file parsing implementation + * XTC ebook support for CrossPoint Reader + */ + +#include "XtcParser.h" + +#include +#include + +#include + +namespace xtc { + +XtcParser::XtcParser() + : m_isOpen(false), + m_defaultWidth(DISPLAY_WIDTH), + m_defaultHeight(DISPLAY_HEIGHT), + m_bitDepth(1), + 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 (!FsHelpers::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; + } + + 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_title.clear(); + 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 + if (m_header.version > 1) { + Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version); + 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, pages=%u, bitDepth=%u\n", millis(), m_header.magic, + (m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, 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; +} + +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) { + File file = SD.open(filepath, FILE_READ); + if (!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 new file mode 100644 index 00000000..b0a402aa --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.h @@ -0,0 +1,96 @@ +/** + * 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; } + + // Validation + static bool isValidXtcFile(const char* filepath); + + // Error information + XtcError getLastError() const { return m_lastError; } + + private: + File m_file; + bool m_isOpen; + XtcHeader m_header; + std::vector m_pageTable; + 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) + XtcError m_lastError; + + // Internal helper functions + XtcError readHeader(); + XtcError readPageTable(); + XtcError readTitle(); +}; + +} // namespace xtc diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h new file mode 100644 index 00000000..30761d97 --- /dev/null +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -0,0 +1,147 @@ +/** + * 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 + +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) + uint16_t version; // 0x04: Format version (typically 1) + 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 + +// 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/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 6ff348e5..fdf84b66 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -12,6 +13,20 @@ #include "config.h" #include "images/CrossLarge.h" +namespace { +// Check if path has XTC extension (.xtc or .xtch) +bool isXtcFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + if (ext4 == ".xtc") return true; + if (path.length() >= 5) { + std::string ext5 = path.substr(path.length() - 5); + if (ext5 == ".xtch") return true; + } + return false; +} +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); @@ -176,19 +191,41 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); - if (!lastEpub.load()) { - Serial.println("[SLP] Failed to load last epub"); - return renderDefaultSleepScreen(); - } + std::string coverBmpPath; - if (!lastEpub.generateCoverBmp()) { - Serial.println("[SLP] Failed to generate cover bmp"); - return renderDefaultSleepScreen(); + // Check if the current book is XTC or EPUB + if (isXtcFile(APP_STATE.openEpubPath)) { + // 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 { + // Handle EPUB file + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastEpub.load()) { + Serial.println("[SLP] Failed to load last epub"); + return renderDefaultSleepScreen(); + } + + if (!lastEpub.generateCoverBmp()) { + Serial.println("[SLP] Failed to generate cover bmp"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastEpub.getCoverBmpPath(); } File file; - if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) { + if (FsHelpers::openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index fec0ef0e..e891d773 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -40,8 +40,12 @@ void FileSelectionActivity::loadFiles() { if (file.isDirectory()) { files.emplace_back(filename + "/"); - } else if (filename.substr(filename.length() - 5) == ".epub") { - files.emplace_back(filename); + } else { + std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : ""; + std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : ""; + if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") { + files.emplace_back(filename); + } } file.close(); } @@ -165,7 +169,7 @@ void FileSelectionActivity::render() const { renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", ""); if (files.empty()) { - renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); + renderer.drawText(UI_FONT_ID, 20, 60, "No books found"); renderer.displayBuffer(); return; } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 519a33a2..222cc979 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -5,6 +5,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "Xtc.h" +#include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { @@ -15,6 +17,17 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { return filePath.substr(0, lastSlash); } +bool ReaderActivity::isXtcFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + if (ext4 == ".xtc") return true; + if (path.length() >= 5) { + std::string ext5 = path.substr(path.length() - 5); + if (ext5 == ".xtch") return true; + } + return false; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SD.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -30,54 +43,102 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { return nullptr; } -void ReaderActivity::onSelectEpubFile(const std::string& path) { - currentEpubPath = path; // Track current book path +std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { + if (!SD.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; +} + +void ReaderActivity::onSelectBookFile(const std::string& path) { + currentBookPath = path; // Track current book path exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); - auto epub = loadEpub(path); - if (epub) { - onGoToEpubReader(std::move(epub)); + if (isXtcFile(path)) { + // Load XTC file + auto xtc = loadXtc(path); + if (xtc) { + onGoToXtcReader(std::move(xtc)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load XTC", REGULAR, + EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { - exitActivity(); - enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR, - EInkDisplay::HALF_REFRESH)); - delay(2000); - onGoToFileSelection(); + // Load EPUB file + auto epub = loadEpub(path); + if (epub) { + onGoToEpubReader(std::move(epub)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR, + EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } } -void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { +void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) { exitActivity(); // If coming from a book, start in that book's folder; otherwise start from root - const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); + const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + renderer, inputManager, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath)); } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { const auto epubPath = epub->getPath(); - currentEpubPath = epubPath; + currentBookPath = epubPath; exitActivity(); enterNewActivity(new EpubReaderActivity( renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); }, [this] { onGoBack(); })); } +void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { + const auto xtcPath = xtc->getPath(); + currentBookPath = xtcPath; + exitActivity(); + enterNewActivity(new XtcReaderActivity( + renderer, inputManager, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); - if (initialEpubPath.empty()) { + if (initialBookPath.empty()) { onGoToFileSelection(); // Start from root when entering via Browse return; } - currentEpubPath = initialEpubPath; - auto epub = loadEpub(initialEpubPath); - if (!epub) { - onGoBack(); - return; - } + currentBookPath = initialBookPath; - onGoToEpubReader(std::move(epub)); + if (isXtcFile(initialBookPath)) { + auto xtc = loadXtc(initialBookPath); + if (!xtc) { + onGoBack(); + return; + } + onGoToXtcReader(std::move(xtc)); + } else { + auto epub = loadEpub(initialBookPath); + if (!epub) { + onGoBack(); + return; + } + onGoToEpubReader(std::move(epub)); + } } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 5bb34193..f40417e8 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -4,23 +4,27 @@ #include "../ActivityWithSubactivity.h" class Epub; +class Xtc; class ReaderActivity final : public ActivityWithSubactivity { - std::string initialEpubPath; - std::string currentEpubPath; // Track current book path for navigation + std::string initialBookPath; + std::string currentBookPath; // Track current book path for navigation const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); + static std::unique_ptr loadXtc(const std::string& path); + static bool isXtcFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); - void onSelectEpubFile(const std::string& path); - void onGoToFileSelection(const std::string& fromEpubPath = ""); + void onSelectBookFile(const std::string& path); + void onGoToFileSelection(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); + void onGoToXtcReader(std::unique_ptr xtc); public: - explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath, + explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath, const std::function& onGoBack) : ActivityWithSubactivity("Reader", renderer, inputManager), - initialEpubPath(std::move(initialEpubPath)), + initialBookPath(std::move(initialBookPath)), onGoBack(onGoBack) {} void onEnter() override; }; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp new file mode 100644 index 00000000..aa9de70b --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -0,0 +1,360 @@ +/** + * XtcReaderActivity.cpp + * + * XTC ebook reader activity implementation + * Displays pre-rendered XTC pages on e-ink display + */ + +#include "XtcReaderActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "config.h" + +namespace { +constexpr int pagesPerRefresh = 15; +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() { + Activity::onEnter(); + + if (!xtc) { + return; + } + + renderingMutex = xSemaphoreCreateMutex(); + + xtc->setupCacheDir(); + + // Load saved progress + loadProgress(); + + // Save current XTC as last opened book + APP_STATE.openEpubPath = xtc->getPath(); + APP_STATE.saveToFile(); + + // 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() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + xtc.reset(); +} + +void XtcReaderActivity::loop() { + // Long press BACK (1s+) goes directly to home + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { + onGoBack(); + return; + } + + const bool prevReleased = + inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); + const bool nextReleased = + inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); + + if (!prevReleased && !nextReleased) { + return; + } + + // Handle end of book + if (currentPage >= xtc->getPageCount()) { + currentPage = xtc->getPageCount() - 1; + updateRequired = true; + return; + } + + const bool skipPages = inputManager.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(); // Allow showing "End of book" + } + 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 + if (currentPage >= xtc->getPageCount()) { + // Show end of book screen + renderer.clearScreen(); + renderer.drawCenteredText(UI_FONT_ID, 300, "End of book", true, BOLD); + renderer.displayBuffer(); + return; + } + + 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_FONT_ID, 300, "Memory error", true, 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_FONT_ID, 300, "Page load error", true, 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 = pagesPerRefresh; + } 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 = pagesPerRefresh; + } 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 { + File f; + if (FsHelpers::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() { + File f; + if (FsHelpers::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(); + } +} diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h new file mode 100644 index 00000000..f923d8ad --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.h @@ -0,0 +1,41 @@ +/** + * 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/Activity.h" + +class XtcReaderActivity final : public Activity { + 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; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void saveProgress() const; + void loadProgress(); + + public: + explicit XtcReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr xtc, + const std::function& onGoBack, const std::function& onGoHome) + : Activity("XtcReader", renderer, inputManager), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +};