mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
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.
This commit is contained in:
parent
aff4dc6628
commit
810066d4f2
262
lib/Xtc/Xtc.cpp
Normal file
262
lib/Xtc/Xtc.cpp
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Xtc.cpp
|
||||
*
|
||||
* Main XTC ebook class implementation
|
||||
* XTC ebook support for CrossPoint Reader
|
||||
*/
|
||||
|
||||
#include "Xtc.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SD.h>
|
||||
|
||||
bool Xtc::load() {
|
||||
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
|
||||
|
||||
// Initialize parser
|
||||
parser.reset(new xtc::XtcParser());
|
||||
|
||||
// Open XTC file
|
||||
xtc::XtcError err = parser->open(filepath.c_str());
|
||||
if (err != xtc::XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err));
|
||||
parser.reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Xtc::clearCache() const {
|
||||
if (!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<uint8_t*>(malloc(bitmapSize));
|
||||
if (!pageBuffer) {
|
||||
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load first page (cover)
|
||||
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
|
||||
if (bytesRead == 0) {
|
||||
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis());
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create BMP file
|
||||
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<const uint8_t*>(&fileSize), 4);
|
||||
uint32_t reserved = 0;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
||||
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
||||
|
||||
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
||||
uint32_t dibHeaderSize = 40;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
||||
int32_t width = pageInfo.width;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&width), 4);
|
||||
int32_t height = -static_cast<int32_t>(pageInfo.height); // Negative for top-down
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&height), 4);
|
||||
uint16_t planes = 1;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
||||
uint16_t bitsPerPixel = 1; // 1-bit monochrome
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
||||
uint32_t compression = 0; // BI_RGB (no compression)
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
||||
int32_t ppmX = 2835; // 72 DPI
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
||||
int32_t ppmY = 2835;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
||||
uint32_t colorsUsed = 2;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
||||
uint32_t colorsImportant = 2;
|
||||
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
||||
|
||||
// Color palette (2 colors for 1-bit)
|
||||
// XTC 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<xtc::XtcParser*>(parser.get())->loadPage(pageIndex, buffer, bufferSize);
|
||||
}
|
||||
|
||||
xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex,
|
||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
||||
size_t chunkSize) const {
|
||||
if (!loaded || !parser) {
|
||||
return xtc::XtcError::FILE_NOT_FOUND;
|
||||
}
|
||||
return const_cast<xtc::XtcParser*>(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize);
|
||||
}
|
||||
|
||||
uint8_t Xtc::calculateProgress(uint32_t currentPage) const {
|
||||
if (!loaded || !parser || parser->getPageCount() == 0) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<uint8_t>((currentPage + 1) * 100 / parser->getPageCount());
|
||||
}
|
||||
|
||||
xtc::XtcError Xtc::getLastError() const {
|
||||
if (!parser) {
|
||||
return xtc::XtcError::FILE_NOT_FOUND;
|
||||
}
|
||||
return parser->getLastError();
|
||||
}
|
||||
96
lib/Xtc/Xtc.h
Normal file
96
lib/Xtc/Xtc.h
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Xtc.h
|
||||
*
|
||||
* Main XTC ebook class for CrossPoint Reader
|
||||
* Provides EPUB-like interface for XTC file handling
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "Xtc/XtcParser.h"
|
||||
#include "Xtc/XtcTypes.h"
|
||||
|
||||
/**
|
||||
* XTC Ebook Handler
|
||||
*
|
||||
* Handles XTC file loading, page access, and cover image generation.
|
||||
* Interface is designed to be similar to Epub class for easy integration.
|
||||
*/
|
||||
class Xtc {
|
||||
std::string filepath;
|
||||
std::string cachePath;
|
||||
std::unique_ptr<xtc::XtcParser> parser;
|
||||
bool loaded;
|
||||
|
||||
public:
|
||||
explicit Xtc(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)), loaded(false) {
|
||||
// Create cache key based on filepath (same as Epub)
|
||||
cachePath = cacheDir + "/xtc_" + std::to_string(std::hash<std::string>{}(this->filepath));
|
||||
}
|
||||
~Xtc() = default;
|
||||
|
||||
/**
|
||||
* Load XTC file
|
||||
* @return true on success
|
||||
*/
|
||||
bool load();
|
||||
|
||||
/**
|
||||
* Clear cached data
|
||||
* @return true on success
|
||||
*/
|
||||
bool clearCache() const;
|
||||
|
||||
/**
|
||||
* Setup cache directory
|
||||
*/
|
||||
void setupCacheDir() const;
|
||||
|
||||
// Path accessors
|
||||
const std::string& getCachePath() const { return cachePath; }
|
||||
const std::string& getPath() const { return filepath; }
|
||||
|
||||
// Metadata
|
||||
std::string getTitle() const;
|
||||
|
||||
// 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<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
||||
size_t chunkSize = 1024) const;
|
||||
|
||||
// Progress calculation
|
||||
uint8_t calculateProgress(uint32_t currentPage) const;
|
||||
|
||||
// Check if file is loaded
|
||||
bool isLoaded() const { return loaded; }
|
||||
|
||||
// Error information
|
||||
xtc::XtcError getLastError() const;
|
||||
};
|
||||
290
lib/Xtc/Xtc/XtcParser.cpp
Normal file
290
lib/Xtc/Xtc/XtcParser.cpp
Normal file
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* XtcParser.cpp
|
||||
*
|
||||
* XTC file parsing implementation
|
||||
* XTC ebook support for CrossPoint Reader
|
||||
*/
|
||||
|
||||
#include "XtcParser.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
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<uint8_t*>(&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<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
|
||||
m_title = titleBuf;
|
||||
|
||||
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
XtcError XtcParser::readPageTable() {
|
||||
if (m_header.pageTableOffset == 0) {
|
||||
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
||||
return XtcError::CORRUPTED_HEADER;
|
||||
}
|
||||
|
||||
// Seek to page table
|
||||
if (!m_file.seek(m_header.pageTableOffset)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
m_pageTable.resize(m_header.pageCount);
|
||||
|
||||
// Read page table entries
|
||||
for (uint16_t i = 0; i < m_header.pageCount; i++) {
|
||||
PageTableEntry entry;
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
||||
if (bytesRead != sizeof(PageTableEntry)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
m_pageTable[i].offset = 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<uint8_t*>(&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<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
||||
size_t chunkSize) {
|
||||
if (!m_isOpen) {
|
||||
return XtcError::FILE_NOT_FOUND;
|
||||
}
|
||||
|
||||
if (pageIndex >= m_header.pageCount) {
|
||||
return XtcError::PAGE_OUT_OF_RANGE;
|
||||
}
|
||||
|
||||
const PageInfo& page = m_pageTable[pageIndex];
|
||||
|
||||
// Seek to page data
|
||||
if (!m_file.seek(page.offset)) {
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
// Read and skip XTG header
|
||||
XtgPageHeader xtgHeader;
|
||||
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&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<uint8_t> chunk(chunkSize);
|
||||
size_t totalRead = 0;
|
||||
|
||||
while (totalRead < bitmapSize) {
|
||||
size_t toRead = std::min(chunkSize, bitmapSize - totalRead);
|
||||
size_t bytesRead = m_file.read(chunk.data(), toRead);
|
||||
|
||||
if (bytesRead == 0) {
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
callback(chunk.data(), bytesRead, totalRead);
|
||||
totalRead += bytesRead;
|
||||
}
|
||||
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
bool XtcParser::isValidXtcFile(const char* filepath) {
|
||||
File file = SD.open(filepath, FILE_READ);
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t magic = 0;
|
||||
size_t bytesRead = file.read(reinterpret_cast<uint8_t*>(&magic), sizeof(magic));
|
||||
file.close();
|
||||
|
||||
if (bytesRead != sizeof(magic)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (magic == XTC_MAGIC);
|
||||
}
|
||||
|
||||
} // namespace xtc
|
||||
94
lib/Xtc/Xtc/XtcParser.h
Normal file
94
lib/Xtc/Xtc/XtcParser.h
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* XtcParser.h
|
||||
*
|
||||
* XTC file parsing and page data extraction
|
||||
* XTC ebook support for CrossPoint Reader
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "XtcTypes.h"
|
||||
|
||||
namespace xtc {
|
||||
|
||||
/**
|
||||
* XTC File Parser
|
||||
*
|
||||
* Reads XTC files from SD card and extracts page data.
|
||||
* Designed for ESP32-C3's limited RAM (~380KB) using streaming.
|
||||
*/
|
||||
class XtcParser {
|
||||
public:
|
||||
XtcParser();
|
||||
~XtcParser();
|
||||
|
||||
// File open/close
|
||||
XtcError open(const char* filepath);
|
||||
void close();
|
||||
bool isOpen() const { return m_isOpen; }
|
||||
|
||||
// Header information access
|
||||
const XtcHeader& getHeader() const { return m_header; }
|
||||
uint16_t getPageCount() const { return m_header.pageCount; }
|
||||
uint16_t getWidth() const { return m_defaultWidth; }
|
||||
uint16_t getHeight() const { return m_defaultHeight; }
|
||||
|
||||
// Page information
|
||||
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
|
||||
|
||||
/**
|
||||
* Load page bitmap (raw 1-bit data, skipping XTG header)
|
||||
*
|
||||
* @param pageIndex Page index (0-based)
|
||||
* @param buffer Output buffer (caller allocated)
|
||||
* @param bufferSize Buffer size
|
||||
* @return Number of bytes read on success, 0 on failure
|
||||
*/
|
||||
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize);
|
||||
|
||||
/**
|
||||
* Streaming page load
|
||||
* Memory-efficient method that reads page data in chunks.
|
||||
*
|
||||
* @param pageIndex Page index
|
||||
* @param callback Callback function to receive data chunks
|
||||
* @param chunkSize Chunk size (default: 1024 bytes)
|
||||
* @return Error code
|
||||
*/
|
||||
XtcError loadPageStreaming(uint32_t pageIndex,
|
||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
||||
size_t chunkSize = 1024);
|
||||
|
||||
// Get title from metadata
|
||||
std::string getTitle() const { return m_title; }
|
||||
|
||||
// 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<PageInfo> 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
|
||||
131
lib/Xtc/Xtc/XtcTypes.h
Normal file
131
lib/Xtc/Xtc/XtcTypes.h
Normal file
@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@ -4,6 +4,7 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SD.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<Epub> 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<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ReaderActivity::onSelectEpubFile(const std::string& path) {
|
||||
currentEpubPath = path; // Track current book path
|
||||
std::unique_ptr<Xtc> 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<Xtc>(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> 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> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<void()> onGoBack;
|
||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> 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> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
|
||||
public:
|
||||
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
|
||||
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath,
|
||||
const std::function<void()>& onGoBack)
|
||||
: ActivityWithSubactivity("Reader", renderer, inputManager),
|
||||
initialEpubPath(std::move(initialEpubPath)),
|
||||
initialBookPath(std::move(initialBookPath)),
|
||||
onGoBack(onGoBack) {}
|
||||
void onEnter() override;
|
||||
};
|
||||
|
||||
311
src/activities/reader/XtcReaderActivity.cpp
Normal file
311
src/activities/reader/XtcReaderActivity.cpp
Normal file
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* XtcReaderActivity.cpp
|
||||
*
|
||||
* XTC ebook reader activity implementation
|
||||
* Displays pre-rendered XTC pages on e-ink display
|
||||
*/
|
||||
|
||||
#include "XtcReaderActivity.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <InputManager.h>
|
||||
|
||||
#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<XtcReaderActivity*>(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<uint32_t>(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<uint8_t*>(malloc(pageBufferSize));
|
||||
if (!pageBuffer) {
|
||||
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_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();
|
||||
}
|
||||
}
|
||||
42
src/activities/reader/XtcReaderActivity.h
Normal file
42
src/activities/reader/XtcReaderActivity.h
Normal file
@ -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 <Xtc.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class XtcReaderActivity final : public Activity {
|
||||
std::shared_ptr<Xtc> xtc;
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
bool updateRequired = false;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
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> xtc,
|
||||
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
||||
: Activity("XtcReader", renderer, inputManager), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user