From 810066d4f2c0a5afed8af1fbbe98a41d16571db6 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sat, 27 Dec 2025 19:12:25 +0900 Subject: [PATCH] feat(xtc): add XTC ebook format support Add support for XTC (XTeink X4 native) ebook format with pre-rendered bitmap pages. Key changes: - Add Xtc library with XtcParser for reading XTC files - XTC format: 22-byte XTG page headers with 480x800 1-bit bitmaps - XtcReaderActivity for displaying XTC pages on e-ink display - Correct bit polarity: 0=black, 1=white in XTC format - FileSelectionActivity: detect and handle .xtc files - ReaderActivity: route to XtcReaderActivity for XTC files - Cover BMP generation from first XTC page XTC pages include pre-rendered status bar with page numbers and progress, so no additional overlay is needed. --- lib/Xtc/Xtc.cpp | 262 +++++++++++++++ lib/Xtc/Xtc.h | 96 ++++++ lib/Xtc/Xtc/XtcParser.cpp | 290 ++++++++++++++++ lib/Xtc/Xtc/XtcParser.h | 94 ++++++ lib/Xtc/Xtc/XtcTypes.h | 131 ++++++++ src/activities/boot_sleep/SleepActivity.cpp | 50 ++- .../reader/FileSelectionActivity.cpp | 10 +- src/activities/reader/ReaderActivity.cpp | 100 ++++-- src/activities/reader/ReaderActivity.h | 16 +- src/activities/reader/XtcReaderActivity.cpp | 311 ++++++++++++++++++ src/activities/reader/XtcReaderActivity.h | 42 +++ 11 files changed, 1363 insertions(+), 39 deletions(-) create mode 100644 lib/Xtc/Xtc.cpp create mode 100644 lib/Xtc/Xtc.h create mode 100644 lib/Xtc/Xtc/XtcParser.cpp create mode 100644 lib/Xtc/Xtc/XtcParser.h create mode 100644 lib/Xtc/Xtc/XtcTypes.h create mode 100644 src/activities/reader/XtcReaderActivity.cpp create mode 100644 src/activities/reader/XtcReaderActivity.h diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp new file mode 100644 index 0000000..4fe7b99 --- /dev/null +++ b/lib/Xtc/Xtc.cpp @@ -0,0 +1,262 @@ +/** + * 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; + } + + // Allocate buffer for page data (XTC is always 1-bit monochrome) + const size_t 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 + // XTC stores data as packed bits, same as BMP 1-bit format + // But we need to ensure proper row alignment (4-byte boundary) + const size_t srcRowSize = (pageInfo.width + 7) / 8; // Source row size + + 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(); +} + +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 0000000..c57b01a --- /dev/null +++ b/lib/Xtc/Xtc.h @@ -0,0 +1,96 @@ +/** + * 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; + + /** + * 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 0000000..49a5071 --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -0,0 +1,290 @@ +/** + * XtcParser.cpp + * + * XTC file parsing implementation + * XTC ebook support for CrossPoint Reader + */ + +#include "XtcParser.h" + +#include + +#include + +namespace xtc { + +XtcParser::XtcParser() + : m_isOpen(false), m_defaultWidth(DISPLAY_WIDTH), m_defaultHeight(DISPLAY_HEIGHT), 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 + m_file = SD.open(filepath, FILE_READ); + if (!m_file) { + Serial.printf("[%lu] [XTC] Failed to open file: %s\n", millis(), filepath); + 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 + if (m_header.magic != XTC_MAGIC) { + Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X)\n", millis(), m_header.magic, XTC_MAGIC); + return XtcError::INVALID_MAGIC; + } + + // 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, ver=%u, pages=%u, pageTableOff=%llu, dataOff=%llu\n", millis(), + m_header.magic, m_header.version, m_header.pageCount, m_header.pageTableOffset, m_header.dataOffset); + + 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 = entry.dataOffset; + m_pageTable[i].size = entry.dataSize; + m_pageTable[i].width = entry.width; + m_pageTable[i].height = entry.height; + + // 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 %llu\n", millis(), pageIndex, page.offset); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + // Read XTG header first + XtgPageHeader xtgHeader; + size_t headerRead = m_file.read(reinterpret_cast(&xtgHeader), sizeof(XtgPageHeader)); + if (headerRead != sizeof(XtgPageHeader)) { + Serial.printf("[%lu] [XTC] Failed to read XTG header for page %u\n", millis(), pageIndex); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + // Verify XTG magic + if (xtgHeader.magic != XTG_MAGIC) { + Serial.printf("[%lu] [XTC] Invalid XTG magic for page %u: 0x%08X\n", millis(), pageIndex, xtgHeader.magic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + // Calculate bitmap size + const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.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 XTG header + XtgPageHeader xtgHeader; + size_t headerRead = m_file.read(reinterpret_cast(&xtgHeader), sizeof(XtgPageHeader)); + if (headerRead != sizeof(XtgPageHeader) || xtgHeader.magic != XTG_MAGIC) { + return XtcError::READ_ERROR; + } + + // Calculate bitmap size + const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.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); +} + +} // namespace xtc diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h new file mode 100644 index 0000000..23d01b0 --- /dev/null +++ b/lib/Xtc/Xtc/XtcParser.h @@ -0,0 +1,94 @@ +/** + * 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; } + + // 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; + 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 0000000..9e501cf --- /dev/null +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -0,0 +1,131 @@ +/** + * 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 +// "XTG\0" = 0x58, 0x54, 0x47, 0x00 +constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for 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 page data header (22 bytes) +#pragma pack(push, 1) +struct XtgPageHeader { + uint32_t magic; // 0x00: Magic "XTG\0" (0x00475458) + uint16_t width; // 0x04: Bitmap width + uint16_t height; // 0x06: Bitmap height + uint16_t reserved1; // 0x08: Reserved (0) + uint32_t bitmapSize; // 0x0A: Bitmap data size = ((width+7)/8) * height + uint32_t reserved2; // 0x0E: Reserved (0) + uint32_t reserved3; // 0x12: Reserved (0) + // Followed by bitmap data at offset 0x16 (22) +}; +#pragma pack(pop) + +// Page information (internal use) +struct PageInfo { + uint64_t offset; // File offset to page data + uint32_t size; // Data size (bytes) + uint16_t width; // Page width + uint16_t height; // Page height +}; + +// 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 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, ".xtg") == 0 || strcasecmp(ext, ".xth") == 0); +} + +} // namespace xtc diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4bc70f5..040a06f 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,15 @@ #include "config.h" #include "images/CrossLarge.h" +namespace { +// Check if path has XTC extension +bool isXtcFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext = path.substr(path.length() - 4); + return (ext == ".xtc" || ext == ".xtg" || ext == ".xth"); +} +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); @@ -176,19 +186,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 853b06f..e37b8e8 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -40,8 +40,14 @@ void FileSelectionActivity::loadFiles() { if (file.isDirectory()) { files.emplace_back(filename + "/"); - } else if (filename.substr(filename.length() - 5) == ".epub") { + } else if (filename.length() >= 5 && filename.substr(filename.length() - 5) == ".epub") { files.emplace_back(filename); + } else if (filename.length() >= 4) { + // Check for XTC format extensions (.xtc, .xtg, .xth) + std::string ext = filename.substr(filename.length() - 4); + if (ext == ".xtc" || ext == ".xtg" || ext == ".xth") { + files.emplace_back(filename); + } } file.close(); } @@ -165,7 +171,7 @@ void FileSelectionActivity::render() const { renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); 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 519a33a..7f9ac7a 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,12 @@ 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 ext = path.substr(path.length() - 4); + return (ext == ".xtc" || ext == ".xtg" || ext == ".xth"); +} + 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 +38,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 5bb3419..f40417e 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 0000000..6a989f6 --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -0,0 +1,311 @@ +/** + * XtcReaderActivity.cpp + * + * XTC ebook reader activity implementation + * Displays pre-rendered XTC pages on e-ink display + */ + +#include "XtcReaderActivity.h" + +#include +#include +#include + +#include "Battery.h" +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "config.h" + +namespace { +constexpr int pagesPerRefresh = 15; +constexpr unsigned long skipPageMs = 700; +constexpr unsigned long goHomeMs = 1000; +constexpr int statusBarHeight = 24; +constexpr int marginLeft = 10; +constexpr int marginRight = 10; +} // 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(); + + // Calculate buffer size for one page (XTC is always 1-bit monochrome) + const size_t 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 stores 1-bit packed data in portrait (480x800) format + const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width + + // XTC pages are pre-rendered with status bar included, so render full page + const uint16_t maxSrcY = pageHeight; + + 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 + + // Use GfxRenderer's drawPixel with logical portrait coordinates + // drawPixel(x, y, state) where state=true draws black + 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\n", millis(), currentPage + 1, xtc->getPageCount()); +} + +void XtcReaderActivity::renderStatusBar() const { + const int screenWidth = GfxRenderer::getScreenWidth(); + const int screenHeight = GfxRenderer::getScreenHeight(); + constexpr int textY = 776; + + // Calculate progress + const uint8_t progress = xtc->calculateProgress(currentPage); + + // Right aligned: page number and progress + const std::string progressText = std::to_string(currentPage + 1) + "/" + std::to_string(xtc->getPageCount()) + " " + + std::to_string(progress) + "%"; + const int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressText.c_str()); + renderer.drawText(SMALL_FONT_ID, screenWidth - marginRight - progressTextWidth, textY, progressText.c_str()); + + // Left aligned: battery + const uint16_t percentage = battery.readPercentage(); + const std::string percentageText = std::to_string(percentage) + "%"; + const int percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); + + // Battery icon + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 10; + constexpr int x = marginLeft; + constexpr int y = 783; + + // Battery outline + renderer.drawLine(x, y, x + batteryWidth - 4, y); + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x, y, x, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // Battery fill + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; + } + renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); + + // Center: book title + const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; + const int titleMarginRight = progressTextWidth + 30 + marginRight; + const int availableTextWidth = screenWidth - titleMarginLeft - titleMarginRight; + + std::string title = xtc->getTitle(); + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + while (titleWidth > availableTextWidth && title.length() > 11) { + title.replace(title.length() - 8, 8, "..."); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); +} + +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 0000000..a751c25 --- /dev/null +++ b/src/activities/reader/XtcReaderActivity.h @@ -0,0 +1,42 @@ +/** + * 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 renderStatusBar() const; + 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; +};