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:
@@ -4,6 +4,7 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SD.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -12,6 +13,20 @@
|
||||
#include "config.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() {
|
||||
Activity::onEnter();
|
||||
renderPopup("Entering Sleep...");
|
||||
@@ -176,19 +191,41 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastEpub.load()) {
|
||||
Serial.println("[SLP] Failed to load last epub");
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
std::string coverBmpPath;
|
||||
|
||||
if (!lastEpub.generateCoverBmp()) {
|
||||
Serial.println("[SLP] Failed to generate cover bmp");
|
||||
return renderDefaultSleepScreen();
|
||||
// Check if the current book is XTC or EPUB
|
||||
if (isXtcFile(APP_STATE.openEpubPath)) {
|
||||
// Handle XTC file
|
||||
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastXtc.load()) {
|
||||
Serial.println("[SLP] Failed to load last XTC");
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
if (!lastXtc.generateCoverBmp()) {
|
||||
Serial.println("[SLP] Failed to generate XTC cover bmp");
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||
} else {
|
||||
// Handle EPUB file
|
||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastEpub.load()) {
|
||||
Serial.println("[SLP] Failed to load last epub");
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
if (!lastEpub.generateCoverBmp()) {
|
||||
Serial.println("[SLP] Failed to generate cover bmp");
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
coverBmpPath = lastEpub.getCoverBmpPath();
|
||||
}
|
||||
|
||||
File file;
|
||||
if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
|
||||
if (FsHelpers::openFileForRead("SLP", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
|
||||
@@ -40,8 +40,12 @@ void FileSelectionActivity::loadFiles() {
|
||||
|
||||
if (file.isDirectory()) {
|
||||
files.emplace_back(filename + "/");
|
||||
} else if (filename.substr(filename.length() - 5) == ".epub") {
|
||||
files.emplace_back(filename);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
@@ -165,7 +169,7 @@ void FileSelectionActivity::render() const {
|
||||
renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", "");
|
||||
|
||||
if (files.empty()) {
|
||||
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");
|
||||
renderer.drawText(UI_FONT_ID, 20, 60, "No books found");
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "FileSelectionActivity.h"
|
||||
#include "Xtc.h"
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
|
||||
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
||||
@@ -15,6 +17,17 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
||||
return filePath.substr(0, lastSlash);
|
||||
}
|
||||
|
||||
bool ReaderActivity::isXtcFile(const std::string& path) {
|
||||
if (path.length() < 4) return false;
|
||||
std::string 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) {
|
||||
if (!SD.exists(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;
|
||||
}
|
||||
|
||||
void ReaderActivity::onSelectEpubFile(const std::string& path) {
|
||||
currentEpubPath = path; // Track current book path
|
||||
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||
if (!SD.exists(path.c_str())) {
|
||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
|
||||
if (xtc->load()) {
|
||||
return xtc;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ReaderActivity::onSelectBookFile(const std::string& path) {
|
||||
currentBookPath = path; // Track current book path
|
||||
exitActivity();
|
||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
|
||||
|
||||
auto epub = loadEpub(path);
|
||||
if (epub) {
|
||||
onGoToEpubReader(std::move(epub));
|
||||
if (isXtcFile(path)) {
|
||||
// Load XTC file
|
||||
auto xtc = loadXtc(path);
|
||||
if (xtc) {
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else {
|
||||
exitActivity();
|
||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load XTC", REGULAR,
|
||||
EInkDisplay::HALF_REFRESH));
|
||||
delay(2000);
|
||||
onGoToFileSelection();
|
||||
}
|
||||
} else {
|
||||
exitActivity();
|
||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
|
||||
EInkDisplay::HALF_REFRESH));
|
||||
delay(2000);
|
||||
onGoToFileSelection();
|
||||
// Load EPUB file
|
||||
auto epub = loadEpub(path);
|
||||
if (epub) {
|
||||
onGoToEpubReader(std::move(epub));
|
||||
} else {
|
||||
exitActivity();
|
||||
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
|
||||
EInkDisplay::HALF_REFRESH));
|
||||
delay(2000);
|
||||
onGoToFileSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) {
|
||||
void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
|
||||
exitActivity();
|
||||
// If coming from a book, start in that book's folder; otherwise start from root
|
||||
const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath);
|
||||
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
|
||||
enterNewActivity(new FileSelectionActivity(
|
||||
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath));
|
||||
renderer, inputManager, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
||||
const auto epubPath = epub->getPath();
|
||||
currentEpubPath = epubPath;
|
||||
currentBookPath = epubPath;
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderActivity(
|
||||
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
|
||||
[this] { onGoBack(); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
||||
const auto xtcPath = xtc->getPath();
|
||||
currentBookPath = xtcPath;
|
||||
exitActivity();
|
||||
enterNewActivity(new XtcReaderActivity(
|
||||
renderer, inputManager, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); },
|
||||
[this] { onGoBack(); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
if (initialEpubPath.empty()) {
|
||||
if (initialBookPath.empty()) {
|
||||
onGoToFileSelection(); // Start from root when entering via Browse
|
||||
return;
|
||||
}
|
||||
|
||||
currentEpubPath = initialEpubPath;
|
||||
auto epub = loadEpub(initialEpubPath);
|
||||
if (!epub) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
currentBookPath = initialBookPath;
|
||||
|
||||
onGoToEpubReader(std::move(epub));
|
||||
if (isXtcFile(initialBookPath)) {
|
||||
auto xtc = loadXtc(initialBookPath);
|
||||
if (!xtc) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else {
|
||||
auto epub = loadEpub(initialBookPath);
|
||||
if (!epub) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToEpubReader(std::move(epub));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,27 @@
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
|
||||
class Epub;
|
||||
class Xtc;
|
||||
|
||||
class ReaderActivity final : public ActivityWithSubactivity {
|
||||
std::string initialEpubPath;
|
||||
std::string currentEpubPath; // Track current book path for navigation
|
||||
std::string initialBookPath;
|
||||
std::string currentBookPath; // Track current book path for navigation
|
||||
const std::function<void()> onGoBack;
|
||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
|
||||
static std::string extractFolderPath(const std::string& filePath);
|
||||
void onSelectEpubFile(const std::string& path);
|
||||
void onGoToFileSelection(const std::string& fromEpubPath = "");
|
||||
void onSelectBookFile(const std::string& path);
|
||||
void onGoToFileSelection(const std::string& fromBookPath = "");
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
|
||||
public:
|
||||
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
|
||||
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath,
|
||||
const std::function<void()>& onGoBack)
|
||||
: ActivityWithSubactivity("Reader", renderer, inputManager),
|
||||
initialEpubPath(std::move(initialEpubPath)),
|
||||
initialBookPath(std::move(initialBookPath)),
|
||||
onGoBack(onGoBack) {}
|
||||
void onEnter() override;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user