mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
## Summary * **What is the goal of this PR?** Add support for XTC (XTeink X4 native) ebook format, which contains pre-rendered 480x800 1-bit bitmap pages optimized for e-ink displays. * **What changes are included?** - New `lib/Xtc/` library with XtcParser for reading XTC files - XtcReaderActivity for displaying XTC pages on e-ink display - XTC file detection in FileSelectionActivity - Cover BMP generation from first XTC page - Correct XTG page header structure (22 bytes) and bit polarity handling ## Additional Context - XTC files contain pre-rendered bitmap pages with embedded status bar (page numbers, progress %) - XTG page header: 22 bytes (magic + dimensions + reserved fields + bitmap size) - Bit polarity: 0 = black, 1 = white - No runtime text rendering needed - pages display directly on e-ink - Faster page display compared to EPUB since no parsing/rendering required - Memory efficient: loads one page at a time (48KB per page) - Tested with XTC files generated from https://x4converter.rho.sh/ - Verified correct page alignment and color rendering - Please report any issues if you test with XTC files from other sources. --------- Co-authored-by: Dave Allie <dave@daveallie.com>
317 lines
9.2 KiB
C++
317 lines
9.2 KiB
C++
/**
|
|
* XtcParser.cpp
|
|
*
|
|
* XTC file parsing implementation
|
|
* XTC ebook support for CrossPoint Reader
|
|
*/
|
|
|
|
#include "XtcParser.h"
|
|
|
|
#include <FsHelpers.h>
|
|
#include <HardwareSerial.h>
|
|
|
|
#include <cstring>
|
|
|
|
namespace xtc {
|
|
|
|
XtcParser::XtcParser()
|
|
: m_isOpen(false),
|
|
m_defaultWidth(DISPLAY_WIDTH),
|
|
m_defaultHeight(DISPLAY_HEIGHT),
|
|
m_bitDepth(1),
|
|
m_lastError(XtcError::OK) {
|
|
memset(&m_header, 0, sizeof(m_header));
|
|
}
|
|
|
|
XtcParser::~XtcParser() { close(); }
|
|
|
|
XtcError XtcParser::open(const char* filepath) {
|
|
// Close if already open
|
|
if (m_isOpen) {
|
|
close();
|
|
}
|
|
|
|
// Open file
|
|
if (!FsHelpers::openFileForRead("XTC", filepath, m_file)) {
|
|
m_lastError = XtcError::FILE_NOT_FOUND;
|
|
return m_lastError;
|
|
}
|
|
|
|
// Read header
|
|
m_lastError = readHeader();
|
|
if (m_lastError != XtcError::OK) {
|
|
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError));
|
|
m_file.close();
|
|
return m_lastError;
|
|
}
|
|
|
|
// Read title if available
|
|
readTitle();
|
|
|
|
// Read page table
|
|
m_lastError = readPageTable();
|
|
if (m_lastError != XtcError::OK) {
|
|
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError));
|
|
m_file.close();
|
|
return m_lastError;
|
|
}
|
|
|
|
m_isOpen = true;
|
|
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount,
|
|
m_defaultWidth, m_defaultHeight);
|
|
return XtcError::OK;
|
|
}
|
|
|
|
void XtcParser::close() {
|
|
if (m_isOpen) {
|
|
m_file.close();
|
|
m_isOpen = false;
|
|
}
|
|
m_pageTable.clear();
|
|
m_title.clear();
|
|
memset(&m_header, 0, sizeof(m_header));
|
|
}
|
|
|
|
XtcError XtcParser::readHeader() {
|
|
// Read first 56 bytes of header
|
|
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&m_header), sizeof(XtcHeader));
|
|
if (bytesRead != sizeof(XtcHeader)) {
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
// Verify magic number (accept both XTC and XTCH)
|
|
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
|
|
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic,
|
|
XTC_MAGIC, XTCH_MAGIC);
|
|
return XtcError::INVALID_MAGIC;
|
|
}
|
|
|
|
// Determine bit depth from file magic
|
|
m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1;
|
|
|
|
// Check version
|
|
if (m_header.version > 1) {
|
|
Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version);
|
|
return XtcError::INVALID_VERSION;
|
|
}
|
|
|
|
// Basic validation
|
|
if (m_header.pageCount == 0) {
|
|
return XtcError::CORRUPTED_HEADER;
|
|
}
|
|
|
|
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
|
|
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, m_header.pageCount, m_bitDepth);
|
|
|
|
return XtcError::OK;
|
|
}
|
|
|
|
XtcError XtcParser::readTitle() {
|
|
// Title is usually at offset 0x38 (56) for 88-byte headers
|
|
// Read title as null-terminated UTF-8 string
|
|
if (m_header.titleOffset == 0) {
|
|
m_header.titleOffset = 0x38; // Default offset
|
|
}
|
|
|
|
if (!m_file.seek(m_header.titleOffset)) {
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
char titleBuf[128] = {0};
|
|
m_file.read(reinterpret_cast<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
|
|
m_title = titleBuf;
|
|
|
|
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
|
return XtcError::OK;
|
|
}
|
|
|
|
XtcError XtcParser::readPageTable() {
|
|
if (m_header.pageTableOffset == 0) {
|
|
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
|
return XtcError::CORRUPTED_HEADER;
|
|
}
|
|
|
|
// Seek to page table
|
|
if (!m_file.seek(m_header.pageTableOffset)) {
|
|
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset);
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
m_pageTable.resize(m_header.pageCount);
|
|
|
|
// Read page table entries
|
|
for (uint16_t i = 0; i < m_header.pageCount; i++) {
|
|
PageTableEntry entry;
|
|
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
|
if (bytesRead != sizeof(PageTableEntry)) {
|
|
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i);
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
|
|
m_pageTable[i].size = entry.dataSize;
|
|
m_pageTable[i].width = entry.width;
|
|
m_pageTable[i].height = entry.height;
|
|
m_pageTable[i].bitDepth = m_bitDepth;
|
|
|
|
// Update default dimensions from first page
|
|
if (i == 0) {
|
|
m_defaultWidth = entry.width;
|
|
m_defaultHeight = entry.height;
|
|
}
|
|
}
|
|
|
|
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount);
|
|
return XtcError::OK;
|
|
}
|
|
|
|
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
|
|
if (pageIndex >= m_pageTable.size()) {
|
|
return false;
|
|
}
|
|
info = m_pageTable[pageIndex];
|
|
return true;
|
|
}
|
|
|
|
size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) {
|
|
if (!m_isOpen) {
|
|
m_lastError = XtcError::FILE_NOT_FOUND;
|
|
return 0;
|
|
}
|
|
|
|
if (pageIndex >= m_header.pageCount) {
|
|
m_lastError = XtcError::PAGE_OUT_OF_RANGE;
|
|
return 0;
|
|
}
|
|
|
|
const PageInfo& page = m_pageTable[pageIndex];
|
|
|
|
// Seek to page data
|
|
if (!m_file.seek(page.offset)) {
|
|
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset);
|
|
m_lastError = XtcError::READ_ERROR;
|
|
return 0;
|
|
}
|
|
|
|
// Read page header (XTG for 1-bit, XTH for 2-bit - same structure)
|
|
XtgPageHeader pageHeader;
|
|
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
|
|
if (headerRead != sizeof(XtgPageHeader)) {
|
|
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex);
|
|
m_lastError = XtcError::READ_ERROR;
|
|
return 0;
|
|
}
|
|
|
|
// Verify page magic (XTG for 1-bit, XTH for 2-bit)
|
|
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
|
|
if (pageHeader.magic != expectedMagic) {
|
|
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex,
|
|
pageHeader.magic, expectedMagic);
|
|
m_lastError = XtcError::INVALID_MAGIC;
|
|
return 0;
|
|
}
|
|
|
|
// Calculate bitmap size based on bit depth
|
|
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
|
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
|
|
size_t bitmapSize;
|
|
if (m_bitDepth == 2) {
|
|
// XTH: two bit planes, each containing (width * height) bits rounded up to bytes
|
|
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
|
|
} else {
|
|
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
|
|
}
|
|
|
|
// Check buffer size
|
|
if (bufferSize < bitmapSize) {
|
|
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize);
|
|
m_lastError = XtcError::MEMORY_ERROR;
|
|
return 0;
|
|
}
|
|
|
|
// Read bitmap data
|
|
size_t bytesRead = m_file.read(buffer, bitmapSize);
|
|
if (bytesRead != bitmapSize) {
|
|
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead);
|
|
m_lastError = XtcError::READ_ERROR;
|
|
return 0;
|
|
}
|
|
|
|
m_lastError = XtcError::OK;
|
|
return bytesRead;
|
|
}
|
|
|
|
XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
|
|
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
size_t chunkSize) {
|
|
if (!m_isOpen) {
|
|
return XtcError::FILE_NOT_FOUND;
|
|
}
|
|
|
|
if (pageIndex >= m_header.pageCount) {
|
|
return XtcError::PAGE_OUT_OF_RANGE;
|
|
}
|
|
|
|
const PageInfo& page = m_pageTable[pageIndex];
|
|
|
|
// Seek to page data
|
|
if (!m_file.seek(page.offset)) {
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
// Read and skip page header (XTG for 1-bit, XTH for 2-bit)
|
|
XtgPageHeader pageHeader;
|
|
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
|
|
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
|
|
if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) {
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
// Calculate bitmap size based on bit depth
|
|
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
|
// XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes
|
|
size_t bitmapSize;
|
|
if (m_bitDepth == 2) {
|
|
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
|
|
} else {
|
|
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
|
|
}
|
|
|
|
// Read in chunks
|
|
std::vector<uint8_t> chunk(chunkSize);
|
|
size_t totalRead = 0;
|
|
|
|
while (totalRead < bitmapSize) {
|
|
size_t toRead = std::min(chunkSize, bitmapSize - totalRead);
|
|
size_t bytesRead = m_file.read(chunk.data(), toRead);
|
|
|
|
if (bytesRead == 0) {
|
|
return XtcError::READ_ERROR;
|
|
}
|
|
|
|
callback(chunk.data(), bytesRead, totalRead);
|
|
totalRead += bytesRead;
|
|
}
|
|
|
|
return XtcError::OK;
|
|
}
|
|
|
|
bool XtcParser::isValidXtcFile(const char* filepath) {
|
|
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 || magic == XTCH_MAGIC);
|
|
}
|
|
|
|
} // namespace xtc
|