Add support for XTC (XTeink X4 native) ebook format with pre-rendered bitmap pages. Key changes: - Add Xtc library with XtcParser for reading XTC files - XTC format: 22-byte XTG page headers with 480x800 1-bit bitmaps - XtcReaderActivity for displaying XTC pages on e-ink display - Correct bit polarity: 0=black, 1=white in XTC format - FileSelectionActivity: detect and handle .xtc files - ReaderActivity: route to XtcReaderActivity for XTC files - Cover BMP generation from first XTC page XTC pages include pre-rendered status bar with page numbers and progress, so no additional overlay is needed.
263 lines
7.7 KiB
C++
263 lines
7.7 KiB
C++
/**
|
|
* Xtc.cpp
|
|
*
|
|
* Main XTC ebook class implementation
|
|
* XTC ebook support for CrossPoint Reader
|
|
*/
|
|
|
|
#include "Xtc.h"
|
|
|
|
#include <FsHelpers.h>
|
|
#include <HardwareSerial.h>
|
|
#include <SD.h>
|
|
|
|
bool Xtc::load() {
|
|
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
|
|
|
|
// Initialize parser
|
|
parser.reset(new xtc::XtcParser());
|
|
|
|
// Open XTC file
|
|
xtc::XtcError err = parser->open(filepath.c_str());
|
|
if (err != xtc::XtcError::OK) {
|
|
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err));
|
|
parser.reset();
|
|
return false;
|
|
}
|
|
|
|
loaded = true;
|
|
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount());
|
|
return true;
|
|
}
|
|
|
|
bool Xtc::clearCache() const {
|
|
if (!SD.exists(cachePath.c_str())) {
|
|
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
|
|
return true;
|
|
}
|
|
|
|
if (!FsHelpers::removeDir(cachePath.c_str())) {
|
|
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis());
|
|
return false;
|
|
}
|
|
|
|
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis());
|
|
return true;
|
|
}
|
|
|
|
void Xtc::setupCacheDir() const {
|
|
if (SD.exists(cachePath.c_str())) {
|
|
return;
|
|
}
|
|
|
|
// Create directories recursively
|
|
for (size_t i = 1; i < cachePath.length(); i++) {
|
|
if (cachePath[i] == '/') {
|
|
SD.mkdir(cachePath.substr(0, i).c_str());
|
|
}
|
|
}
|
|
SD.mkdir(cachePath.c_str());
|
|
}
|
|
|
|
std::string Xtc::getTitle() const {
|
|
if (!loaded || !parser) {
|
|
return "";
|
|
}
|
|
|
|
// Try to get title from XTC metadata first
|
|
std::string title = parser->getTitle();
|
|
if (!title.empty()) {
|
|
return title;
|
|
}
|
|
|
|
// Fallback: extract filename from path as title
|
|
size_t lastSlash = filepath.find_last_of('/');
|
|
size_t lastDot = filepath.find_last_of('.');
|
|
|
|
if (lastSlash == std::string::npos) {
|
|
lastSlash = 0;
|
|
} else {
|
|
lastSlash++;
|
|
}
|
|
|
|
if (lastDot == std::string::npos || lastDot <= lastSlash) {
|
|
return filepath.substr(lastSlash);
|
|
}
|
|
|
|
return filepath.substr(lastSlash, lastDot - lastSlash);
|
|
}
|
|
|
|
std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
|
|
|
bool Xtc::generateCoverBmp() const {
|
|
// Already generated
|
|
if (SD.exists(getCoverBmpPath().c_str())) {
|
|
return true;
|
|
}
|
|
|
|
if (!loaded || !parser) {
|
|
Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis());
|
|
return false;
|
|
}
|
|
|
|
if (parser->getPageCount() == 0) {
|
|
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
|
|
return false;
|
|
}
|
|
|
|
// Setup cache directory
|
|
setupCacheDir();
|
|
|
|
// Get first page info for cover
|
|
xtc::PageInfo pageInfo;
|
|
if (!parser->getPageInfo(0, pageInfo)) {
|
|
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
|
|
return false;
|
|
}
|
|
|
|
// Allocate buffer for page data (XTC is always 1-bit monochrome)
|
|
const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
|
if (!pageBuffer) {
|
|
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
|
return false;
|
|
}
|
|
|
|
// Load first page (cover)
|
|
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
|
|
if (bytesRead == 0) {
|
|
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis());
|
|
free(pageBuffer);
|
|
return false;
|
|
}
|
|
|
|
// Create BMP file
|
|
File coverBmp;
|
|
if (!FsHelpers::openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
|
|
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis());
|
|
free(pageBuffer);
|
|
return false;
|
|
}
|
|
|
|
// Write BMP header
|
|
// BMP file header (14 bytes)
|
|
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes
|
|
const uint32_t imageSize = rowSize * pageInfo.height;
|
|
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
|
|
|
|
// File header
|
|
coverBmp.write('B');
|
|
coverBmp.write('M');
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
|
|
uint32_t reserved = 0;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
|
uint32_t dibHeaderSize = 40;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
int32_t width = pageInfo.width;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&width), 4);
|
|
int32_t height = -static_cast<int32_t>(pageInfo.height); // Negative for top-down
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&height), 4);
|
|
uint16_t planes = 1;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
uint16_t bitsPerPixel = 1; // 1-bit monochrome
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
uint32_t compression = 0; // BI_RGB (no compression)
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
|
int32_t ppmX = 2835; // 72 DPI
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
|
int32_t ppmY = 2835;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
|
uint32_t colorsUsed = 2;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
|
uint32_t colorsImportant = 2;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
|
|
|
|
// Color palette (2 colors for 1-bit)
|
|
// XTC uses inverted polarity: 0 = black, 1 = white
|
|
// Color 0: Black (text/foreground in XTC)
|
|
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
|
|
coverBmp.write(black, 4);
|
|
// Color 1: White (background in XTC)
|
|
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
|
|
coverBmp.write(white, 4);
|
|
|
|
// Write bitmap data
|
|
// XTC stores data as packed bits, same as BMP 1-bit format
|
|
// But we need to ensure proper row alignment (4-byte boundary)
|
|
const size_t srcRowSize = (pageInfo.width + 7) / 8; // Source row size
|
|
|
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
|
// Write source row
|
|
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
|
|
|
|
// Pad to 4-byte boundary
|
|
uint8_t padding[4] = {0, 0, 0, 0};
|
|
size_t paddingSize = rowSize - srcRowSize;
|
|
if (paddingSize > 0) {
|
|
coverBmp.write(padding, paddingSize);
|
|
}
|
|
}
|
|
|
|
coverBmp.close();
|
|
free(pageBuffer);
|
|
|
|
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
|
|
return true;
|
|
}
|
|
|
|
uint32_t Xtc::getPageCount() const {
|
|
if (!loaded || !parser) {
|
|
return 0;
|
|
}
|
|
return parser->getPageCount();
|
|
}
|
|
|
|
uint16_t Xtc::getPageWidth() const {
|
|
if (!loaded || !parser) {
|
|
return 0;
|
|
}
|
|
return parser->getWidth();
|
|
}
|
|
|
|
uint16_t Xtc::getPageHeight() const {
|
|
if (!loaded || !parser) {
|
|
return 0;
|
|
}
|
|
return parser->getHeight();
|
|
}
|
|
|
|
size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const {
|
|
if (!loaded || !parser) {
|
|
return 0;
|
|
}
|
|
return const_cast<xtc::XtcParser*>(parser.get())->loadPage(pageIndex, buffer, bufferSize);
|
|
}
|
|
|
|
xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex,
|
|
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
size_t chunkSize) const {
|
|
if (!loaded || !parser) {
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
}
|
|
return const_cast<xtc::XtcParser*>(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize);
|
|
}
|
|
|
|
uint8_t Xtc::calculateProgress(uint32_t currentPage) const {
|
|
if (!loaded || !parser || parser->getPageCount() == 0) {
|
|
return 0;
|
|
}
|
|
return static_cast<uint8_t>((currentPage + 1) * 100 / parser->getPageCount());
|
|
}
|
|
|
|
xtc::XtcError Xtc::getLastError() const {
|
|
if (!parser) {
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
}
|
|
return parser->getLastError();
|
|
}
|