Add XTC/XTCH ebook format support (#135)

## 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>
This commit is contained in:
Eunchurn Park
2025-12-28 23:56:05 +09:00
committed by GitHub
parent 3dc5f6fec4
commit f9b604f04e
14 changed files with 1598 additions and 44 deletions

316
lib/Xtc/Xtc/XtcParser.cpp Normal file
View File

@@ -0,0 +1,316 @@
/**
* 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

96
lib/Xtc/Xtc/XtcParser.h Normal file
View File

@@ -0,0 +1,96 @@
/**
* 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; }
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
// Page information
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
/**
* Load page bitmap (raw 1-bit data, skipping XTG header)
*
* @param pageIndex Page index (0-based)
* @param buffer Output buffer (caller allocated)
* @param bufferSize Buffer size
* @return Number of bytes read on success, 0 on failure
*/
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize);
/**
* Streaming page load
* Memory-efficient method that reads page data in chunks.
*
* @param pageIndex Page index
* @param callback Callback function to receive data chunks
* @param chunkSize Chunk size (default: 1024 bytes)
* @return Error code
*/
XtcError loadPageStreaming(uint32_t pageIndex,
std::function<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;
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
XtcError m_lastError;
// Internal helper functions
XtcError readHeader();
XtcError readPageTable();
XtcError readTitle();
};
} // namespace xtc

147
lib/Xtc/Xtc/XtcTypes.h Normal file
View File

@@ -0,0 +1,147 @@
/**
* XtcTypes.h
*
* XTC file format type definitions
* XTC ebook support for CrossPoint Reader
*
* XTC is the native binary ebook format for XTeink X4 e-reader.
* It stores pre-rendered bitmap images per page.
*
* Format based on EPUB2XTC converter by Rafal-P-Mazur
*/
#pragma once
#include <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 (1-bit fast mode)
// "XTCH" = 0x58, 0x54, 0x43, 0x48
constexpr uint32_t XTCH_MAGIC = 0x48435458; // "XTCH" in little-endian (2-bit high quality mode)
// "XTG\0" = 0x58, 0x54, 0x47, 0x00
constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for 1-bit page data
// "XTH\0" = 0x58, 0x54, 0x48, 0x00
constexpr uint32_t XTH_MAGIC = 0x00485458; // "XTH\0" for 2-bit page data
// XTeink X4 display resolution
constexpr uint16_t DISPLAY_WIDTH = 480;
constexpr uint16_t DISPLAY_HEIGHT = 800;
// XTC file header (56 bytes)
#pragma pack(push, 1)
struct XtcHeader {
uint32_t magic; // 0x00: Magic number "XTC\0" (0x00435458)
uint16_t version; // 0x04: Format version (typically 1)
uint16_t pageCount; // 0x06: Total page count
uint32_t flags; // 0x08: Flags/reserved
uint32_t headerSize; // 0x0C: Size of header section (typically 88)
uint32_t reserved1; // 0x10: Reserved
uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8!
uint64_t pageTableOffset; // 0x18: Page table offset
uint64_t dataOffset; // 0x20: First page data offset
uint64_t reserved2; // 0x28: Reserved
uint32_t titleOffset; // 0x30: Title string offset
uint32_t padding; // 0x34: Padding to 56 bytes
};
#pragma pack(pop)
// Page table entry (16 bytes per page)
#pragma pack(push, 1)
struct PageTableEntry {
uint64_t dataOffset; // 0x00: Absolute offset to page data
uint32_t dataSize; // 0x08: Page data size in bytes
uint16_t width; // 0x0C: Page width (480)
uint16_t height; // 0x0E: Page height (800)
};
#pragma pack(pop)
// XTG/XTH page data header (22 bytes)
// Used for both 1-bit (XTG) and 2-bit (XTH) formats
#pragma pack(push, 1)
struct XtgPageHeader {
uint32_t magic; // 0x00: File identifier (XTG: 0x00475458, XTH: 0x00485458)
uint16_t width; // 0x04: Image width (pixels)
uint16_t height; // 0x06: Image height (pixels)
uint8_t colorMode; // 0x08: Color mode (0=monochrome)
uint8_t compression; // 0x09: Compression (0=uncompressed)
uint32_t dataSize; // 0x0A: Image data size (bytes)
uint64_t md5; // 0x0E: MD5 checksum (first 8 bytes, optional)
// Followed by bitmap data at offset 0x16 (22)
//
// XTG (1-bit): Row-major, 8 pixels/byte, MSB first
// dataSize = ((width + 7) / 8) * height
//
// XTH (2-bit): Two bit planes, column-major (right-to-left), 8 vertical pixels/byte
// dataSize = ((width * height + 7) / 8) * 2
// First plane: Bit1 for all pixels
// Second plane: Bit2 for all pixels
// pixelValue = (bit1 << 1) | bit2
};
#pragma pack(pop)
// Page information (internal use, optimized for memory)
struct PageInfo {
uint32_t offset; // File offset to page data (max 4GB file size)
uint32_t size; // Data size (bytes)
uint16_t width; // Page width
uint16_t height; // Page height
uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale)
uint8_t padding; // Alignment padding
}; // 16 bytes total
// Error codes
enum class XtcError {
OK = 0,
FILE_NOT_FOUND,
INVALID_MAGIC,
INVALID_VERSION,
CORRUPTED_HEADER,
PAGE_OUT_OF_RANGE,
READ_ERROR,
WRITE_ERROR,
MEMORY_ERROR,
DECOMPRESSION_ERROR,
};
// Convert error code to string
inline const char* errorToString(XtcError err) {
switch (err) {
case XtcError::OK:
return "OK";
case XtcError::FILE_NOT_FOUND:
return "File not found";
case XtcError::INVALID_MAGIC:
return "Invalid magic number";
case XtcError::INVALID_VERSION:
return "Unsupported version";
case XtcError::CORRUPTED_HEADER:
return "Corrupted header";
case XtcError::PAGE_OUT_OF_RANGE:
return "Page out of range";
case XtcError::READ_ERROR:
return "Read error";
case XtcError::WRITE_ERROR:
return "Write error";
case XtcError::MEMORY_ERROR:
return "Memory allocation error";
case XtcError::DECOMPRESSION_ERROR:
return "Decompression error";
default:
return "Unknown error";
}
}
/**
* Check if filename has XTC/XTCH extension
*/
inline bool isXtcExtension(const char* filename) {
if (!filename) return false;
const char* ext = strrchr(filename, '.');
if (!ext) return false;
return (strcasecmp(ext, ".xtc") == 0 || strcasecmp(ext, ".xtch") == 0);
}
} // namespace xtc