mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
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:
parent
3dc5f6fec4
commit
f9b604f04e
@ -341,12 +341,13 @@ void GfxRenderer::freeBwBufferChunks() {
|
|||||||
* This should be called before grayscale buffers are populated.
|
* This should be called before grayscale buffers are populated.
|
||||||
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
|
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
|
||||||
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
|
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
|
||||||
|
* Returns true if buffer was stored successfully, false if allocation failed.
|
||||||
*/
|
*/
|
||||||
void GfxRenderer::storeBwBuffer() {
|
bool GfxRenderer::storeBwBuffer() {
|
||||||
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate and copy each chunk
|
// Allocate and copy each chunk
|
||||||
@ -367,7 +368,7 @@ void GfxRenderer::storeBwBuffer() {
|
|||||||
BW_BUFFER_CHUNK_SIZE);
|
BW_BUFFER_CHUNK_SIZE);
|
||||||
// Free previously allocated chunks
|
// Free previously allocated chunks
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
|
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
|
||||||
@ -375,6 +376,7 @@ void GfxRenderer::storeBwBuffer() {
|
|||||||
|
|
||||||
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS,
|
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS,
|
||||||
BW_BUFFER_CHUNK_SIZE);
|
BW_BUFFER_CHUNK_SIZE);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -422,6 +424,17 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup grayscale buffers using the current frame buffer.
|
||||||
|
* Use this when BW buffer was re-rendered instead of stored/restored.
|
||||||
|
*/
|
||||||
|
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||||
|
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
||||||
|
if (frameBuffer) {
|
||||||
|
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
|
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
|
||||||
const bool pixelState, const EpdFontStyle style) const {
|
const bool pixelState, const EpdFontStyle style) const {
|
||||||
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
||||||
|
|||||||
@ -85,8 +85,9 @@ class GfxRenderer {
|
|||||||
void copyGrayscaleLsbBuffers() const;
|
void copyGrayscaleLsbBuffers() const;
|
||||||
void copyGrayscaleMsbBuffers() const;
|
void copyGrayscaleMsbBuffers() const;
|
||||||
void displayGrayBuffer() const;
|
void displayGrayBuffer() const;
|
||||||
void storeBwBuffer();
|
bool storeBwBuffer(); // Returns true if buffer was stored successfully
|
||||||
void restoreBwBuffer();
|
void restoreBwBuffer();
|
||||||
|
void cleanupGrayscaleWithFrameBuffer() const;
|
||||||
|
|
||||||
// Low level functions
|
// Low level functions
|
||||||
uint8_t* getFrameBuffer() const;
|
uint8_t* getFrameBuffer() const;
|
||||||
|
|||||||
40
lib/Xtc/README
Normal file
40
lib/Xtc/README
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# XTC/XTCH Library
|
||||||
|
|
||||||
|
XTC ebook format support for CrossPoint Reader.
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
|
||||||
|
| Format | Extension | Description |
|
||||||
|
|--------|-----------|----------------------------------------------|
|
||||||
|
| XTC | `.xtc` | Container with XTG pages (1-bit monochrome) |
|
||||||
|
| XTCH | `.xtch` | Container with XTH pages (2-bit grayscale) |
|
||||||
|
|
||||||
|
## Format Overview
|
||||||
|
|
||||||
|
XTC/XTCH are container formats designed for ESP32 e-paper displays. They store pre-rendered bitmap pages optimized for the XTeink X4 e-reader (480x800 resolution).
|
||||||
|
|
||||||
|
### Container Structure (XTC/XTCH)
|
||||||
|
|
||||||
|
- 56-byte header with metadata offsets
|
||||||
|
- Optional metadata (title, author, etc.)
|
||||||
|
- Page index table (16 bytes per page)
|
||||||
|
- Page data (XTG or XTH format)
|
||||||
|
|
||||||
|
### Page Formats
|
||||||
|
|
||||||
|
#### XTG (1-bit monochrome)
|
||||||
|
|
||||||
|
- Row-major storage, 8 pixels per byte
|
||||||
|
- MSB first (bit 7 = leftmost pixel)
|
||||||
|
- 0 = Black, 1 = White
|
||||||
|
|
||||||
|
#### XTH (2-bit grayscale)
|
||||||
|
|
||||||
|
- Two bit planes stored sequentially
|
||||||
|
- Column-major order (right to left)
|
||||||
|
- 8 vertical pixels per byte
|
||||||
|
- Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Original format info: <https://gist.github.com/CrazyCoder/b125f26d6987c0620058249f59f1327d>
|
||||||
337
lib/Xtc/Xtc.cpp
Normal file
337
lib/Xtc/Xtc.cpp
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bit depth
|
||||||
|
const uint8_t bitDepth = parser->getBitDepth();
|
||||||
|
|
||||||
|
// Allocate buffer for page data
|
||||||
|
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
||||||
|
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
|
||||||
|
size_t bitmapSize;
|
||||||
|
if (bitDepth == 2) {
|
||||||
|
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
// BMP requires 4-byte row alignment
|
||||||
|
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
|
||||||
|
|
||||||
|
if (bitDepth == 2) {
|
||||||
|
// XTH 2-bit mode: Two bit planes, column-major order
|
||||||
|
// - Columns scanned right to left (x = width-1 down to 0)
|
||||||
|
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
||||||
|
// - First plane: Bit1, Second plane: Bit2
|
||||||
|
// - Pixel value = (bit1 << 1) | bit2
|
||||||
|
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
|
||||||
|
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
||||||
|
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
||||||
|
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
|
||||||
|
|
||||||
|
// Allocate a row buffer for 1-bit output
|
||||||
|
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
|
||||||
|
if (!rowBuffer) {
|
||||||
|
free(pageBuffer);
|
||||||
|
coverBmp.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
||||||
|
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
|
||||||
|
|
||||||
|
for (uint16_t x = 0; x < pageInfo.width; x++) {
|
||||||
|
// Column-major, right to left: column index = (width - 1 - x)
|
||||||
|
const size_t colIndex = pageInfo.width - 1 - x;
|
||||||
|
const size_t byteInCol = y / 8;
|
||||||
|
const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel
|
||||||
|
|
||||||
|
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
||||||
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
||||||
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
||||||
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
||||||
|
|
||||||
|
// Threshold: 0=white (1); 1,2,3=black (0)
|
||||||
|
if (pixelValue >= 1) {
|
||||||
|
// Set bit to 0 (black) in BMP format
|
||||||
|
const size_t dstByte = x / 8;
|
||||||
|
const size_t dstBit = 7 - (x % 8);
|
||||||
|
rowBuffer[dstByte] &= ~(1 << dstBit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write converted row
|
||||||
|
coverBmp.write(rowBuffer, dstRowSize);
|
||||||
|
|
||||||
|
// Pad to 4-byte boundary
|
||||||
|
uint8_t padding[4] = {0, 0, 0, 0};
|
||||||
|
size_t paddingSize = rowSize - dstRowSize;
|
||||||
|
if (paddingSize > 0) {
|
||||||
|
coverBmp.write(padding, paddingSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(rowBuffer);
|
||||||
|
} else {
|
||||||
|
// 1-bit source: write directly with proper padding
|
||||||
|
const size_t srcRowSize = (pageInfo.width + 7) / 8;
|
||||||
|
|
||||||
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
||||||
|
// Write source row
|
||||||
|
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
|
||||||
|
|
||||||
|
// Pad to 4-byte boundary
|
||||||
|
uint8_t padding[4] = {0, 0, 0, 0};
|
||||||
|
size_t paddingSize = rowSize - srcRowSize;
|
||||||
|
if (paddingSize > 0) {
|
||||||
|
coverBmp.write(padding, paddingSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coverBmp.close();
|
||||||
|
free(pageBuffer);
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t Xtc::getPageCount() const {
|
||||||
|
if (!loaded || !parser) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return parser->getPageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t Xtc::getPageWidth() const {
|
||||||
|
if (!loaded || !parser) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return parser->getWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t Xtc::getPageHeight() const {
|
||||||
|
if (!loaded || !parser) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return parser->getHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t Xtc::getBitDepth() const {
|
||||||
|
if (!loaded || !parser) {
|
||||||
|
return 1; // Default to 1-bit
|
||||||
|
}
|
||||||
|
return parser->getBitDepth();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const {
|
||||||
|
if (!loaded || !parser) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return const_cast<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();
|
||||||
|
}
|
||||||
97
lib/Xtc/Xtc.h
Normal file
97
lib/Xtc/Xtc.h
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
uint8_t getBitDepth() const; // 1 = XTC (1-bit), 2 = XTCH (2-bit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load page bitmap data
|
||||||
|
* @param pageIndex Page index (0-based)
|
||||||
|
* @param buffer Output buffer
|
||||||
|
* @param bufferSize Buffer size
|
||||||
|
* @return Number of bytes read
|
||||||
|
*/
|
||||||
|
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load page with streaming callback
|
||||||
|
* @param pageIndex Page index
|
||||||
|
* @param callback Callback for each chunk
|
||||||
|
* @param chunkSize Chunk size
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
xtc::XtcError loadPageStreaming(uint32_t pageIndex,
|
||||||
|
std::function<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;
|
||||||
|
};
|
||||||
316
lib/Xtc/Xtc/XtcParser.cpp
Normal file
316
lib/Xtc/Xtc/XtcParser.cpp
Normal 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
96
lib/Xtc/Xtc/XtcParser.h
Normal 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
147
lib/Xtc/Xtc/XtcTypes.h
Normal 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
|
||||||
@ -4,6 +4,7 @@
|
|||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@ -12,6 +13,20 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "images/CrossLarge.h"
|
#include "images/CrossLarge.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Check if path has XTC extension (.xtc or .xtch)
|
||||||
|
bool isXtcFile(const std::string& path) {
|
||||||
|
if (path.length() < 4) return false;
|
||||||
|
std::string ext4 = path.substr(path.length() - 4);
|
||||||
|
if (ext4 == ".xtc") return true;
|
||||||
|
if (path.length() >= 5) {
|
||||||
|
std::string ext5 = path.substr(path.length() - 5);
|
||||||
|
if (ext5 == ".xtch") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void SleepActivity::onEnter() {
|
void SleepActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
renderPopup("Entering Sleep...");
|
renderPopup("Entering Sleep...");
|
||||||
@ -176,19 +191,41 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
return renderDefaultSleepScreen();
|
return renderDefaultSleepScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
std::string coverBmpPath;
|
||||||
if (!lastEpub.load()) {
|
|
||||||
Serial.println("[SLP] Failed to load last epub");
|
|
||||||
return renderDefaultSleepScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastEpub.generateCoverBmp()) {
|
// Check if the current book is XTC or EPUB
|
||||||
Serial.println("[SLP] Failed to generate cover bmp");
|
if (isXtcFile(APP_STATE.openEpubPath)) {
|
||||||
return renderDefaultSleepScreen();
|
// 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;
|
File file;
|
||||||
if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
|
if (FsHelpers::openFileForRead("SLP", coverBmpPath, file)) {
|
||||||
Bitmap bitmap(file);
|
Bitmap bitmap(file);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
renderBitmapSleepScreen(bitmap);
|
renderBitmapSleepScreen(bitmap);
|
||||||
|
|||||||
@ -40,8 +40,12 @@ void FileSelectionActivity::loadFiles() {
|
|||||||
|
|
||||||
if (file.isDirectory()) {
|
if (file.isDirectory()) {
|
||||||
files.emplace_back(filename + "/");
|
files.emplace_back(filename + "/");
|
||||||
} else if (filename.substr(filename.length() - 5) == ".epub") {
|
} else {
|
||||||
files.emplace_back(filename);
|
std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : "";
|
||||||
|
std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : "";
|
||||||
|
if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") {
|
||||||
|
files.emplace_back(filename);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
@ -165,7 +169,7 @@ void FileSelectionActivity::render() const {
|
|||||||
renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", "");
|
renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", "");
|
||||||
|
|
||||||
if (files.empty()) {
|
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();
|
renderer.displayBuffer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
#include "EpubReaderActivity.h"
|
#include "EpubReaderActivity.h"
|
||||||
#include "FileSelectionActivity.h"
|
#include "FileSelectionActivity.h"
|
||||||
|
#include "Xtc.h"
|
||||||
|
#include "XtcReaderActivity.h"
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
|
|
||||||
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
||||||
@ -15,6 +17,17 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
|||||||
return filePath.substr(0, lastSlash);
|
return filePath.substr(0, lastSlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ReaderActivity::isXtcFile(const std::string& path) {
|
||||||
|
if (path.length() < 4) return false;
|
||||||
|
std::string ext4 = path.substr(path.length() - 4);
|
||||||
|
if (ext4 == ".xtc") return true;
|
||||||
|
if (path.length() >= 5) {
|
||||||
|
std::string ext5 = path.substr(path.length() - 5);
|
||||||
|
if (ext5 == ".xtch") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||||
if (!SD.exists(path.c_str())) {
|
if (!SD.exists(path.c_str())) {
|
||||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
@ -30,54 +43,102 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaderActivity::onSelectEpubFile(const std::string& path) {
|
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||||
currentEpubPath = path; // Track current book 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();
|
exitActivity();
|
||||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
|
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
|
||||||
|
|
||||||
auto epub = loadEpub(path);
|
if (isXtcFile(path)) {
|
||||||
if (epub) {
|
// Load XTC file
|
||||||
onGoToEpubReader(std::move(epub));
|
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 {
|
} else {
|
||||||
exitActivity();
|
// Load EPUB file
|
||||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
|
auto epub = loadEpub(path);
|
||||||
EInkDisplay::HALF_REFRESH));
|
if (epub) {
|
||||||
delay(2000);
|
onGoToEpubReader(std::move(epub));
|
||||||
onGoToFileSelection();
|
} 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();
|
exitActivity();
|
||||||
// If coming from a book, start in that book's folder; otherwise start from root
|
// 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(
|
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) {
|
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
||||||
const auto epubPath = epub->getPath();
|
const auto epubPath = epub->getPath();
|
||||||
currentEpubPath = epubPath;
|
currentBookPath = epubPath;
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderActivity(
|
enterNewActivity(new EpubReaderActivity(
|
||||||
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
|
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
|
||||||
[this] { onGoBack(); }));
|
[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() {
|
void ReaderActivity::onEnter() {
|
||||||
ActivityWithSubactivity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
if (initialEpubPath.empty()) {
|
if (initialBookPath.empty()) {
|
||||||
onGoToFileSelection(); // Start from root when entering via Browse
|
onGoToFileSelection(); // Start from root when entering via Browse
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentEpubPath = initialEpubPath;
|
currentBookPath = initialBookPath;
|
||||||
auto epub = loadEpub(initialEpubPath);
|
|
||||||
if (!epub) {
|
|
||||||
onGoBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
|
||||||
class Epub;
|
class Epub;
|
||||||
|
class Xtc;
|
||||||
|
|
||||||
class ReaderActivity final : public ActivityWithSubactivity {
|
class ReaderActivity final : public ActivityWithSubactivity {
|
||||||
std::string initialEpubPath;
|
std::string initialBookPath;
|
||||||
std::string currentEpubPath; // Track current book path for navigation
|
std::string currentBookPath; // Track current book path for navigation
|
||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
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);
|
static std::string extractFolderPath(const std::string& filePath);
|
||||||
void onSelectEpubFile(const std::string& path);
|
void onSelectBookFile(const std::string& path);
|
||||||
void onGoToFileSelection(const std::string& fromEpubPath = "");
|
void onGoToFileSelection(const std::string& fromBookPath = "");
|
||||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||||
|
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
|
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath,
|
||||||
const std::function<void()>& onGoBack)
|
const std::function<void()>& onGoBack)
|
||||||
: ActivityWithSubactivity("Reader", renderer, inputManager),
|
: ActivityWithSubactivity("Reader", renderer, inputManager),
|
||||||
initialEpubPath(std::move(initialEpubPath)),
|
initialBookPath(std::move(initialBookPath)),
|
||||||
onGoBack(onGoBack) {}
|
onGoBack(onGoBack) {}
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
};
|
};
|
||||||
|
|||||||
360
src/activities/reader/XtcReaderActivity.cpp
Normal file
360
src/activities/reader/XtcReaderActivity.cpp
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* 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 "CrossPointSettings.h"
|
||||||
|
#include "CrossPointState.h"
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int pagesPerRefresh = 15;
|
||||||
|
constexpr unsigned long skipPageMs = 700;
|
||||||
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void XtcReaderActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<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();
|
||||||
|
const uint8_t bitDepth = xtc->getBitDepth();
|
||||||
|
|
||||||
|
// Calculate buffer size for one page
|
||||||
|
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
||||||
|
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
|
||||||
|
size_t pageBufferSize;
|
||||||
|
if (bitDepth == 2) {
|
||||||
|
pageBufferSize = ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2;
|
||||||
|
} else {
|
||||||
|
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/XTCH pages are pre-rendered with status bar included, so render full page
|
||||||
|
const uint16_t maxSrcY = pageHeight;
|
||||||
|
|
||||||
|
if (bitDepth == 2) {
|
||||||
|
// XTH 2-bit mode: Two bit planes, column-major order
|
||||||
|
// - Columns scanned right to left (x = width-1 down to 0)
|
||||||
|
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
||||||
|
// - First plane: Bit1, Second plane: Bit2
|
||||||
|
// - Pixel value = (bit1 << 1) | bit2
|
||||||
|
// - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
|
||||||
|
|
||||||
|
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
|
||||||
|
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
||||||
|
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
||||||
|
const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
|
||||||
|
|
||||||
|
// Lambda to get pixel value at (x, y)
|
||||||
|
auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t {
|
||||||
|
const size_t colIndex = pageWidth - 1 - x;
|
||||||
|
const size_t byteInCol = y / 8;
|
||||||
|
const size_t bitInByte = 7 - (y % 8);
|
||||||
|
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
||||||
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
||||||
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
||||||
|
return (bit1 << 1) | bit2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory)
|
||||||
|
// Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame
|
||||||
|
|
||||||
|
// Count pixel distribution for debugging
|
||||||
|
uint32_t pixelCounts[4] = {0, 0, 0, 0};
|
||||||
|
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||||
|
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||||
|
pixelCounts[getPixelValue(x, y)]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(),
|
||||||
|
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]);
|
||||||
|
|
||||||
|
// Pass 1: BW buffer - draw all non-white pixels as black
|
||||||
|
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||||
|
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||||
|
if (getPixelValue(x, y) >= 1) {
|
||||||
|
renderer.drawPixel(x, y, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
||||||
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
|
pagesUntilFullRefresh = pagesPerRefresh;
|
||||||
|
} else {
|
||||||
|
renderer.displayBuffer();
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
||||||
|
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||||
|
renderer.clearScreen(0x00);
|
||||||
|
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||||
|
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||||
|
if (getPixelValue(x, y) == 1) { // Dark grey only
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.copyGrayscaleLsbBuffers();
|
||||||
|
|
||||||
|
// Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2)
|
||||||
|
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||||
|
renderer.clearScreen(0x00);
|
||||||
|
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||||
|
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||||
|
const uint8_t pv = getPixelValue(x, y);
|
||||||
|
if (pv == 1 || pv == 2) { // Dark grey or Light grey
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.copyGrayscaleMsbBuffers();
|
||||||
|
|
||||||
|
// Display grayscale overlay
|
||||||
|
renderer.displayGrayBuffer();
|
||||||
|
|
||||||
|
// Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer)
|
||||||
|
renderer.clearScreen();
|
||||||
|
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||||
|
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||||
|
if (getPixelValue(x, y) >= 1) {
|
||||||
|
renderer.drawPixel(x, y, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup grayscale buffers with current frame buffer
|
||||||
|
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||||
|
|
||||||
|
free(pageBuffer);
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1,
|
||||||
|
xtc->getPageCount());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 1-bit mode: 8 pixels per byte, MSB first
|
||||||
|
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
|
||||||
|
|
||||||
|
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
|
||||||
|
const size_t srcRowStart = srcY * srcRowBytes;
|
||||||
|
|
||||||
|
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
|
||||||
|
// Read source pixel (MSB first, bit 7 = leftmost pixel)
|
||||||
|
const size_t srcByte = srcRowStart + srcX / 8;
|
||||||
|
const size_t srcBit = 7 - (srcX % 8);
|
||||||
|
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
|
||||||
|
|
||||||
|
if (isBlack) {
|
||||||
|
renderer.drawPixel(srcX, srcY, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// White pixels are already cleared by clearScreen()
|
||||||
|
|
||||||
|
free(pageBuffer);
|
||||||
|
|
||||||
|
// XTC pages already have status bar pre-rendered, no need to add our own
|
||||||
|
|
||||||
|
// Display with appropriate refresh
|
||||||
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
|
pagesUntilFullRefresh = pagesPerRefresh;
|
||||||
|
} else {
|
||||||
|
renderer.displayBuffer();
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(),
|
||||||
|
bitDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
void XtcReaderActivity::saveProgress() const {
|
||||||
|
File f;
|
||||||
|
if (FsHelpers::openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
data[0] = currentPage & 0xFF;
|
||||||
|
data[1] = (currentPage >> 8) & 0xFF;
|
||||||
|
data[2] = (currentPage >> 16) & 0xFF;
|
||||||
|
data[3] = (currentPage >> 24) & 0xFF;
|
||||||
|
f.write(data, 4);
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void XtcReaderActivity::loadProgress() {
|
||||||
|
File f;
|
||||||
|
if (FsHelpers::openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
if (f.read(data, 4) == 4) {
|
||||||
|
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
|
||||||
|
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage);
|
||||||
|
|
||||||
|
// Validate page number
|
||||||
|
if (currentPage >= xtc->getPageCount()) {
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/activities/reader/XtcReaderActivity.h
Normal file
41
src/activities/reader/XtcReaderActivity.h
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* XtcReaderActivity.h
|
||||||
|
*
|
||||||
|
* XTC ebook reader activity for CrossPoint Reader
|
||||||
|
* Displays pre-rendered XTC pages on e-ink display
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <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 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