Merge branch 'daveallie:master' into master

This commit is contained in:
iandchasse 2025-12-28 15:22:26 -05:00 committed by GitHub
commit 31ba087997
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3070 additions and 554 deletions

View File

@ -7,7 +7,9 @@ namespace {
constexpr uint8_t PAGE_FILE_VERSION = 3; constexpr uint8_t PAGE_FILE_VERSION = 3;
} }
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
}
void PageLine::serialize(File& file) { void PageLine::serialize(File& file) {
serialization::writePod(file, xPos); serialization::writePod(file, xPos);
@ -27,9 +29,9 @@ std::unique_ptr<PageLine> PageLine::deserialize(File& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
} }
void Page::render(GfxRenderer& renderer, const int fontId) const { void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) { for (auto& element : elements) {
element->render(renderer, fontId); element->render(renderer, fontId, xOffset, yOffset);
} }
} }

View File

@ -17,7 +17,7 @@ class PageElement {
int16_t yPos; int16_t yPos;
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId) = 0; virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual void serialize(File& file) = 0; virtual void serialize(File& file) = 0;
}; };
@ -28,7 +28,7 @@ class PageLine final : public PageElement {
public: public:
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId) override; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
void serialize(File& file) override; void serialize(File& file) override;
static std::unique_ptr<PageLine> deserialize(File& file); static std::unique_ptr<PageLine> deserialize(File& file);
}; };
@ -37,7 +37,7 @@ class Page {
public: public:
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId) const; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void serialize(File& file) const; void serialize(File& file) const;
static std::unique_ptr<Page> deserialize(File& file); static std::unique_ptr<Page> deserialize(File& file);
}; };

View File

@ -18,14 +18,14 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
} }
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin, void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) { const bool includeLastLine) {
if (words.empty()) { if (words.empty()) {
return; return;
} }
const int pageWidth = renderer.getScreenWidth() - horizontalMargin; const int pageWidth = viewportWidth;
const int spaceWidth = renderer.getSpaceWidth(fontId); const int spaceWidth = renderer.getSpaceWidth(fontId);
const auto wordWidths = calculateWordWidths(renderer, fontId); const auto wordWidths = calculateWordWidths(renderer, fontId);
const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths); const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths);
@ -106,21 +106,34 @@ std::vector<size_t> ParsedText::computeLineBreaks(const int pageWidth, const int
ans[i] = j; // j is the index of the last word in this optimal line ans[i] = j; // j is the index of the last word in this optimal line
} }
} }
// Handle oversized word: if no valid configuration found, force single-word line
// This prevents cascade failure where one oversized word breaks all preceding words
if (dp[i] == MAX_COST) {
ans[i] = i; // Just this word on its own line
// Inherit cost from next word to allow subsequent words to find valid configurations
if (i + 1 < static_cast<int>(totalWordCount)) {
dp[i] = dp[i + 1];
} else {
dp[i] = 0;
}
}
} }
// Stores the index of the word that starts the next line (last_word_index + 1) // Stores the index of the word that starts the next line (last_word_index + 1)
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
size_t currentWordIndex = 0; size_t currentWordIndex = 0;
constexpr size_t MAX_LINES = 1000;
while (currentWordIndex < totalWordCount) { while (currentWordIndex < totalWordCount) {
if (lineBreakIndices.size() >= MAX_LINES) { size_t nextBreakIndex = ans[currentWordIndex] + 1;
break;
// Safety check: prevent infinite loop if nextBreakIndex doesn't advance
if (nextBreakIndex <= currentWordIndex) {
// Force advance by at least one word to avoid infinite loop
nextBreakIndex = currentWordIndex + 1;
} }
size_t nextBreakIndex = ans[currentWordIndex] + 1;
lineBreakIndices.push_back(nextBreakIndex); lineBreakIndices.push_back(nextBreakIndex);
currentWordIndex = nextBreakIndex; currentWordIndex = nextBreakIndex;
} }

View File

@ -34,7 +34,7 @@ class ParsedText {
TextBlock::BLOCK_STYLE getStyle() const { return style; } TextBlock::BLOCK_STYLE getStyle() const { return style; }
size_t size() const { return words.size(); } size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); } bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin, void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true); bool includeLastLine = true);
}; };

View File

@ -8,8 +8,8 @@
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
namespace { namespace {
constexpr uint8_t SECTION_FILE_VERSION = 5; constexpr uint8_t SECTION_FILE_VERSION = 6;
} } // namespace
void Section::onPageComplete(std::unique_ptr<Page> page) { void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
@ -26,9 +26,8 @@ void Section::onPageComplete(std::unique_ptr<Page> page) {
pageCount++; pageCount++;
} }
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, void Section::writeCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int marginRight, const int marginBottom, const int marginLeft, const int viewportWidth, const int viewportHeight) const {
const bool extraParagraphSpacing) const {
File outputFile; File outputFile;
if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) {
return; return;
@ -36,18 +35,15 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, SECTION_FILE_VERSION);
serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, fontId);
serialization::writePod(outputFile, lineCompression); serialization::writePod(outputFile, lineCompression);
serialization::writePod(outputFile, marginTop);
serialization::writePod(outputFile, marginRight);
serialization::writePod(outputFile, marginBottom);
serialization::writePod(outputFile, marginLeft);
serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, viewportWidth);
serialization::writePod(outputFile, viewportHeight);
serialization::writePod(outputFile, pageCount); serialization::writePod(outputFile, pageCount);
outputFile.close(); outputFile.close();
} }
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int marginRight, const int marginBottom, const int marginLeft, const int viewportWidth, const int viewportHeight) {
const bool extraParagraphSpacing) {
const auto sectionFilePath = cachePath + "/section.bin"; const auto sectionFilePath = cachePath + "/section.bin";
File inputFile; File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) { if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
@ -65,20 +61,18 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c
return false; return false;
} }
int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft; int fileFontId, fileViewportWidth, fileViewportHeight;
float fileLineCompression; float fileLineCompression;
bool fileExtraParagraphSpacing; bool fileExtraParagraphSpacing;
serialization::readPod(inputFile, fileFontId); serialization::readPod(inputFile, fileFontId);
serialization::readPod(inputFile, fileLineCompression); serialization::readPod(inputFile, fileLineCompression);
serialization::readPod(inputFile, fileMarginTop);
serialization::readPod(inputFile, fileMarginRight);
serialization::readPod(inputFile, fileMarginBottom);
serialization::readPod(inputFile, fileMarginLeft);
serialization::readPod(inputFile, fileExtraParagraphSpacing); serialization::readPod(inputFile, fileExtraParagraphSpacing);
serialization::readPod(inputFile, fileViewportWidth);
serialization::readPod(inputFile, fileViewportHeight);
if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop || if (fontId != fileFontId || lineCompression != fileLineCompression ||
marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft || extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
extraParagraphSpacing != fileExtraParagraphSpacing) { viewportHeight != fileViewportHeight) {
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache(); clearCache();
@ -113,28 +107,58 @@ bool Section::clearCache() const {
return true; return true;
} }
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int marginRight, const int marginBottom, const int marginLeft, const int viewportWidth, const int viewportHeight,
const bool extraParagraphSpacing) { const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href; const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { // Retry logic for SD card timing issues
return false; bool success = false;
size_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
delay(50); // Brief delay before retry
}
// Remove any incomplete file from previous attempt before retrying
if (SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
}
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!success && SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
}
} }
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis());
return false; return false;
} }
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, // Only show progress bar for larger chapters where rendering overhead is worth it
marginLeft, extraParagraphSpacing, if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); progressSetupFn();
}
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }, progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str()); SD.remove(tmpHtmlPath.c_str());
@ -143,7 +167,7 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
return false; return false;
} }
writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing); writeCacheMetadata(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
return true; return true;
} }

View File

@ -1,4 +1,5 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include "Epub.h" #include "Epub.h"
@ -12,8 +13,8 @@ class Section {
GfxRenderer& renderer; GfxRenderer& renderer;
std::string cachePath; std::string cachePath;
void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, void writeCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int marginLeft, bool extraParagraphSpacing) const; int viewportHeight) const;
void onPageComplete(std::unique_ptr<Page> page); void onPageComplete(std::unique_ptr<Page> page);
public: public:
@ -26,11 +27,12 @@ class Section {
renderer(renderer), renderer(renderer),
cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {} cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {}
~Section() = default; ~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool loadCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int marginLeft, bool extraParagraphSpacing); int viewportHeight);
void setupCacheDir() const; void setupCacheDir() const;
bool clearCache() const; bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool persistPageDataToSD(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int marginLeft, bool extraParagraphSpacing); int viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSD() const; std::unique_ptr<Page> loadPageFromSD() const;
}; };

View File

@ -4,11 +4,18 @@
#include <Serialization.h> #include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return;
}
auto wordIt = words.begin(); auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin(); auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin(); auto wordXposIt = wordXpos.begin();
for (int i = 0; i < words.size(); i++) { for (size_t i = 0; i < words.size(); i++) {
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
std::advance(wordIt, 1); std::advance(wordIt, 1);
@ -46,6 +53,13 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
// words // words
serialization::readPod(file, wc); serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;
}
words.resize(wc); words.resize(wc);
for (auto& w : words) serialization::readString(file, w); for (auto& w : words) serialization::readString(file, w);
@ -59,6 +73,13 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
wordStyles.resize(sc); wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(file, s); for (auto& s : wordStyles) serialization::readPod(file, s);
// Validate data consistency: all three lists must have the same size
if (wc != xc || wc != sc) {
Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc,
xc, sc);
return nullptr;
}
// style // style
serialization::readPod(file, style); serialization::readPod(file, style);

View File

@ -11,6 +11,9 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@ -152,7 +155,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
if (self->currentTextBlock->size() > 750) { if (self->currentTextBlock->size() > 750) {
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis()); Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
self->currentTextBlock->layoutAndExtractLines( self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->marginLeft + self->marginRight, self->renderer, self->fontId, self->viewportWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false); [self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
} }
} }
@ -221,6 +224,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Get file size for progress calculation
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
XML_SetUserData(parser, this); XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement); XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData); XML_SetCharacterDataHandler(parser, characterData);
@ -249,6 +257,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
@ -282,15 +301,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) { void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom;
if (currentPageNextY + lineHeight > pageHeight) { if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage)); completePageFn(std::move(currentPage));
currentPage.reset(new Page()); currentPage.reset(new Page());
currentPageNextY = marginTop; currentPageNextY = 0;
} }
currentPage->elements.push_back(std::make_shared<PageLine>(line, marginLeft, currentPageNextY)); currentPage->elements.push_back(std::make_shared<PageLine>(line, 0, currentPageNextY));
currentPageNextY += lineHeight; currentPageNextY += lineHeight;
} }
@ -302,12 +320,12 @@ void ChapterHtmlSlimParser::makePages() {
if (!currentPage) { if (!currentPage) {
currentPage.reset(new Page()); currentPage.reset(new Page());
currentPageNextY = marginTop; currentPageNextY = 0;
} }
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
currentTextBlock->layoutAndExtractLines( currentTextBlock->layoutAndExtractLines(
renderer, fontId, marginLeft + marginRight, renderer, fontId, viewportWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); }); [this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
// Extra paragraph spacing if enabled // Extra paragraph spacing if enabled
if (extraParagraphSpacing) { if (extraParagraphSpacing) {

View File

@ -18,6 +18,7 @@ class ChapterHtmlSlimParser {
const std::string& filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0; int depth = 0;
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
@ -31,11 +32,9 @@ class ChapterHtmlSlimParser {
int16_t currentPageNextY = 0; int16_t currentPageNextY = 0;
int fontId; int fontId;
float lineCompression; float lineCompression;
int marginTop;
int marginRight;
int marginBottom;
int marginLeft;
bool extraParagraphSpacing; bool extraParagraphSpacing;
int viewportWidth;
int viewportHeight;
void startNewTextBlock(TextBlock::BLOCK_STYLE style); void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void makePages(); void makePages();
@ -46,19 +45,19 @@ class ChapterHtmlSlimParser {
public: public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight, const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const int viewportHeight,
const std::function<void(std::unique_ptr<Page>)>& completePageFn) const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
fontId(fontId), fontId(fontId),
lineCompression(lineCompression), lineCompression(lineCompression),
marginTop(marginTop),
marginRight(marginRight),
marginBottom(marginBottom),
marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn) {} viewportWidth(viewportWidth),
viewportHeight(viewportHeight),
completePageFn(completePageFn),
progressFn(progressFn) {}
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);

View File

@ -3,6 +3,126 @@
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Brightness adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true
constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
const int product = gray * 255;
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
static inline uint8_t quantizeNoise(int gray, int x, int y) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Floyd-Steinberg quantization with error diffusion and serpentine scanning
// Returns 2-bit value (0-3) and updates error buffers
static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow,
bool reverseDir) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDir) {
// Left to right
errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16
errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16
} else {
// Right to left (mirrored)
errorCurRow[x] += (error * 7) >> 4; // Left: 7/16
errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16
}
return quantized;
}
Bitmap::~Bitmap() {
delete[] errorCurRow;
delete[] errorNextRow;
}
uint16_t Bitmap::readLE16(File& f) { uint16_t Bitmap::readLE16(File& f) {
const int c0 = f.read(); const int c0 = f.read();
const int c1 = f.read(); const int c1 = f.read();
@ -46,6 +166,8 @@ const char* Bitmap::errorToString(BmpReaderError err) {
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
case BmpReaderError::BadDimensions: case BmpReaderError::BadDimensions:
return "BadDimensions"; return "BadDimensions";
case BmpReaderError::ImageTooLarge:
return "ImageTooLarge (max 2048x3072)";
case BmpReaderError::PaletteTooLarge: case BmpReaderError::PaletteTooLarge:
return "PaletteTooLarge"; return "PaletteTooLarge";
@ -99,6 +221,13 @@ BmpReaderError Bitmap::parseHeaders() {
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
return BmpReaderError::ImageTooLarge;
}
// Pre-calculate Row Bytes to avoid doing this every row // Pre-calculate Row Bytes to avoid doing this every row
rowBytes = (width * bpp + 31) / 32 * 4; rowBytes = (width * bpp + 31) / 32 * 4;
@ -115,21 +244,56 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Allocate Floyd-Steinberg error buffers if enabled
if (USE_FLOYD_STEINBERG) {
delete[] errorCurRow;
delete[] errorNextRow;
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
lastRowY = -1;
}
return BmpReaderError::Ok; return BmpReaderError::Ok;
} }
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
// Handle Floyd-Steinberg error buffer progression
const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow;
if (useFS) {
// Check if we need to advance to next row (or reset if jumping)
if (rowY != lastRowY + 1 && rowY != 0) {
// Non-sequential row access - reset error buffers
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
} else if (rowY > 0) {
// Sequential access - swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
lastRowY = rowY;
}
uint8_t* outPtr = data; uint8_t* outPtr = data;
uint8_t currentOutByte = 0; uint8_t currentOutByte = 0;
int bitShift = 6; int bitShift = 6;
int currentX = 0;
// Helper lambda to pack 2bpp color into the output stream // Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](const uint8_t lum) { auto packPixel = [&](const uint8_t lum) {
uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 uint8_t color;
if (useFS) {
// Floyd-Steinberg error diffusion
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false);
} else {
// Simple quantization or noise dithering
color = quantize(lum, currentX, rowY);
}
currentOutByte |= (color << bitShift); currentOutByte |= (color << bitShift);
if (bitShift == 0) { if (bitShift == 0) {
*outPtr++ = currentOutByte; *outPtr++ = currentOutByte;
@ -138,6 +302,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
} else { } else {
bitShift -= 2; bitShift -= 2;
} }
currentX++;
}; };
uint8_t lum; uint8_t lum;
@ -196,5 +361,12 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Reset Floyd-Steinberg error buffers when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
lastRowY = -1;
}
return BmpReaderError::Ok; return BmpReaderError::Ok;
} }

View File

@ -15,6 +15,7 @@ enum class BmpReaderError : uint8_t {
UnsupportedCompression, UnsupportedCompression,
BadDimensions, BadDimensions,
ImageTooLarge,
PaletteTooLarge, PaletteTooLarge,
SeekPixelDataFailed, SeekPixelDataFailed,
@ -28,8 +29,9 @@ class Bitmap {
static const char* errorToString(BmpReaderError err); static const char* errorToString(BmpReaderError err);
explicit Bitmap(File& file) : file(file) {} explicit Bitmap(File& file) : file(file) {}
~Bitmap();
BmpReaderError parseHeaders(); BmpReaderError parseHeaders();
BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const;
BmpReaderError rewindToData() const; BmpReaderError rewindToData() const;
int getWidth() const { return width; } int getWidth() const { return width; }
int getHeight() const { return height; } int getHeight() const { return height; }
@ -49,4 +51,9 @@ class Bitmap {
uint16_t bpp = 0; uint16_t bpp = 0;
int rowBytes = 0; int rowBytes = 0;
uint8_t paletteLum[256] = {}; uint8_t paletteLum[256] = {};
// Floyd-Steinberg dithering state (mutable for const methods)
mutable int16_t* errorCurRow = nullptr;
mutable int16_t* errorNextRow = nullptr;
mutable int lastRowY = -1; // Track row progression for error propagation
}; };

View File

@ -4,6 +4,37 @@
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const {
switch (orientation) {
case Portrait: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise
*rotatedX = y;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
break;
}
case LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y;
break;
}
case PortraitInverted: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedY = x;
break;
}
case LandscapeCounterClockwise: {
// Logical landscape (800x480) aligned with panel orientation
*rotatedX = x;
*rotatedY = y;
break;
}
}
}
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
@ -13,15 +44,14 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
return; return;
} }
// Rotate coordinates: portrait (480x800) -> landscape (800x480) int rotatedX = 0;
// Rotation: 90 degrees clockwise int rotatedY = 0;
const int rotatedX = y; rotateCoordinates(x, y, &rotatedX, &rotatedY);
const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
// Bounds checking (portrait: 480x800) // Bounds checking against physical panel dimensions
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d)\n", millis(), x, y); Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
return; return;
} }
@ -55,7 +85,7 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontStyle style) const { const EpdFontStyle style) const {
const int yPos = y + getLineHeight(fontId); const int yPos = y + getFontAscenderSize(fontId);
int xpos = x; int xpos = x;
// cannot draw a NULL / empty string // cannot draw a NULL / empty string
@ -115,8 +145,11 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int
} }
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
// Flip X and Y for portrait mode // TODO: Rotate bits
einkDisplay.drawImage(bitmap, y, x, height, width); int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(x, y, &rotatedX, &rotatedY);
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
} }
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
@ -132,7 +165,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
isScaled = true; isScaled = true;
} }
const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4; // Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize)); auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
@ -154,7 +189,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
break; break;
} }
if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
@ -203,23 +238,34 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons
einkDisplay.displayBuffer(refreshMode); einkDisplay.displayBuffer(refreshMode);
} }
void GfxRenderer::displayWindow(const int x, const int y, const int width, const int height) const { // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
// Rotate coordinates from portrait (480x800) to landscape (800x480) int GfxRenderer::getScreenWidth() const {
// Rotation: 90 degrees clockwise switch (orientation) {
// Portrait coordinates: (x, y) with dimensions (width, height) case Portrait:
// Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight) case PortraitInverted:
// 480px wide in portrait logical coordinates
const int rotatedX = y; return EInkDisplay::DISPLAY_HEIGHT;
const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1; case LandscapeClockwise:
const int rotatedWidth = height; case LandscapeCounterClockwise:
const int rotatedHeight = width; // 800px wide in landscape logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight); }
return EInkDisplay::DISPLAY_HEIGHT;
} }
// Note: Internal driver treats screen in command orientation, this library treats in portrait orientation int GfxRenderer::getScreenHeight() const {
int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; } switch (orientation) {
int GfxRenderer::getScreenHeight() { return EInkDisplay::DISPLAY_WIDTH; } case Portrait:
case PortraitInverted:
// 800px tall in portrait logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates
return EInkDisplay::DISPLAY_HEIGHT;
}
return EInkDisplay::DISPLAY_WIDTH;
}
int GfxRenderer::getSpaceWidth(const int fontId) const { int GfxRenderer::getSpaceWidth(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
@ -230,6 +276,15 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX; return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX;
} }
int GfxRenderer::getFontAscenderSize(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;
}
return fontMap.at(fontId).getData(REGULAR)->ascender;
}
int GfxRenderer::getLineHeight(const int fontId) const { int GfxRenderer::getLineHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
@ -245,7 +300,7 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
constexpr int buttonWidth = 106; constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40; constexpr int buttonHeight = 40;
constexpr int buttonY = 40; // Distance from bottom constexpr int buttonY = 40; // Distance from bottom
constexpr int textYOffset = 5; // Distance from top of button to text baseline constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350}; constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4}; const char* labels[] = {btn1, btn2, btn3, btn4};
@ -286,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
@ -312,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);
@ -320,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;
} }
/** /**
@ -367,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);
@ -430,3 +498,32 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
*x += glyph->advanceX; *x += glyph->advanceX;
} }
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
switch (orientation) {
case Portrait:
*outTop = VIEWABLE_MARGIN_TOP;
*outRight = VIEWABLE_MARGIN_RIGHT;
*outBottom = VIEWABLE_MARGIN_BOTTOM;
*outLeft = VIEWABLE_MARGIN_LEFT;
break;
case LandscapeClockwise:
*outTop = VIEWABLE_MARGIN_LEFT;
*outRight = VIEWABLE_MARGIN_TOP;
*outBottom = VIEWABLE_MARGIN_RIGHT;
*outLeft = VIEWABLE_MARGIN_BOTTOM;
break;
case PortraitInverted:
*outTop = VIEWABLE_MARGIN_BOTTOM;
*outRight = VIEWABLE_MARGIN_LEFT;
*outBottom = VIEWABLE_MARGIN_TOP;
*outLeft = VIEWABLE_MARGIN_RIGHT;
break;
case LandscapeCounterClockwise:
*outTop = VIEWABLE_MARGIN_RIGHT;
*outRight = VIEWABLE_MARGIN_BOTTOM;
*outBottom = VIEWABLE_MARGIN_LEFT;
*outLeft = VIEWABLE_MARGIN_TOP;
break;
}
}

View File

@ -12,6 +12,14 @@ class GfxRenderer {
public: public:
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
// Logical screen orientation from the perspective of callers
enum Orientation {
Portrait, // 480x800 logical coordinates (current default)
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
PortraitInverted, // 480x800 logical coordinates, inverted
LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation
};
private: private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
@ -20,24 +28,35 @@ class GfxRenderer {
EInkDisplay& einkDisplay; EInkDisplay& einkDisplay;
RenderMode renderMode; RenderMode renderMode;
Orientation orientation;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap; std::map<int, EpdFontFamily> fontMap;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
EpdFontStyle style) const; EpdFontStyle style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
public: public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW) {} explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() = default; ~GfxRenderer() = default;
static constexpr int VIEWABLE_MARGIN_TOP = 9;
static constexpr int VIEWABLE_MARGIN_RIGHT = 3;
static constexpr int VIEWABLE_MARGIN_BOTTOM = 3;
static constexpr int VIEWABLE_MARGIN_LEFT = 3;
// Setup // Setup
void insertFont(int fontId, EpdFontFamily font); void insertFont(int fontId, EpdFontFamily font);
// Orientation control (affects logical width/height and coordinate transforms)
void setOrientation(const Orientation o) { orientation = o; }
Orientation getOrientation() const { return orientation; }
// Screen ops // Screen ops
static int getScreenWidth(); int getScreenWidth() const;
static int getScreenHeight(); int getScreenHeight() const;
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region (portrait coordinates) // EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const; void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const; void clearScreen(uint8_t color = 0xFF) const;
@ -55,6 +74,7 @@ class GfxRenderer {
void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
int getSpaceWidth(int fontId) const; int getSpaceWidth(int fontId) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const; int getLineHeight(int fontId) const;
// UI Components // UI Components
@ -65,11 +85,13 @@ 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;
static size_t getBufferSize(); static size_t getBufferSize();
void grayscaleRevert() const; void grayscaleRevert() const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
}; };

View File

@ -13,24 +13,296 @@ struct JpegReadContext {
size_t bufferFilled; size_t bufferFilled;
}; };
// Helper function: Convert 8-bit grayscale to 2-bit (0-3) // ============================================================================
uint8_t JpegToBmpConverter::grayscaleTo2Bit(const uint8_t grayscale) { // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// Simple threshold mapping: // ============================================================================
// 0-63 -> 0 (black) constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels)
// 64-127 -> 1 (dark gray) // Dithering method selection (only one should be true, or all false for simple quantization):
// 128-191 -> 2 (light gray) constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion)
// 192-255 -> 3 (white) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
return grayscale >> 6; constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
} }
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
static inline int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
gray = adjustPixel(gray);
// Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Map gray (0-255) to 4 levels with dithering
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x, bool reverseDirection) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDirection) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
inline void write16(Print& out, const uint16_t value) { inline void write16(Print& out, const uint16_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 2);
out.write(value & 0xFF); out.write(value & 0xFF);
out.write((value >> 8) & 0xFF); out.write((value >> 8) & 0xFF);
} }
inline void write32(Print& out, const uint32_t value) { inline void write32(Print& out, const uint32_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 4);
out.write(value & 0xFF); out.write(value & 0xFF);
out.write((value >> 8) & 0xFF); out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF); out.write((value >> 16) & 0xFF);
@ -38,13 +310,49 @@ inline void write32(Print& out, const uint32_t value) {
} }
inline void write32Signed(Print& out, const int32_t value) { inline void write32Signed(Print& out, const int32_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 4);
out.write(value & 0xFF); out.write(value & 0xFF);
out.write((value >> 8) & 0xFF); out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF); out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF); out.write((value >> 24) & 0xFF);
} }
// Helper function: Write BMP header with 8-bit grayscale (256 levels)
void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded
const int imageSize = bytesPerRow * height;
const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA)
const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0); // Reserved
write32(bmpOut, 14 + 40 + paletteSize); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 8); // Bits per pixel (8 bits)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 256); // colorsUsed
write32(bmpOut, 256); // colorsImportant
// Color Palette (256 grayscale entries x 4 bytes = 1024 bytes)
for (int i = 0; i < 256; i++) {
bmpOut.write(static_cast<uint8_t>(i)); // Blue
bmpOut.write(static_cast<uint8_t>(i)); // Green
bmpOut.write(static_cast<uint8_t>(i)); // Red
bmpOut.write(static_cast<uint8_t>(0)); // Reserved
}
}
// Helper function: Write BMP header with 2-bit color depth // Helper function: Write BMP header with 2-bit color depth
void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes) // Calculate row padding (each row must be multiple of 4 bytes)
@ -135,13 +443,59 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
// Write BMP header // Safety limits to prevent memory issues on ESP32
writeBmpHeader(bmpOut, imageInfo.m_width, imageInfo.m_height); constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
constexpr int MAX_MCU_ROW_BYTES = 65536;
// Calculate row parameters if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4; Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width,
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
return false;
}
// Allocate row buffer for packed 2-bit pixels // Calculate output dimensions (pre-scale to fit display exactly)
int outWidth = imageInfo.m_width;
int outHeight = imageInfo.m_height;
// Use fixed-point scaling (16.16) for sub-pixel accuracy
uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) {
// Calculate scale to fit within target dimensions while maintaining aspect ratio
const float scaleToFitWidth = static_cast<float>(TARGET_MAX_WIDTH) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(TARGET_MAX_HEIGHT) / imageInfo.m_height;
const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
outWidth = static_cast<int>(imageInfo.m_width * scale);
outHeight = static_cast<int>(imageInfo.m_height * scale);
// Ensure at least 1 pixel
if (outWidth < 1) outWidth = 1;
if (outHeight < 1) outHeight = 1;
// Calculate fixed-point scale factors (source pixels per output pixel)
// scaleX_fp = (srcWidth << 16) / outWidth
scaleX_fp = (static_cast<uint32_t>(imageInfo.m_width) << 16) / outWidth;
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT);
}
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else {
writeBmpHeader(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
// Allocate row buffer
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow)); auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) { if (!rowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
@ -152,13 +506,48 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
// This is the minimal memory needed for streaming conversion // This is the minimal memory needed for streaming conversion
const int mcuPixelHeight = imageInfo.m_MCUHeight; const int mcuPixelHeight = imageInfo.m_MCUHeight;
const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) { // Validate MCU row buffer size before allocation
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer\n", millis()); if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels,
MAX_MCU_ROW_BYTES);
free(rowBuffer); free(rowBuffer);
return false; return false;
} }
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels);
free(rowBuffer);
return false;
}
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
fsDitherer = new FloydSteinbergDitherer(outWidth);
}
}
// For scaling: accumulate source rows into scaled output rows
// We need to track which source Y maps to which output Y
// Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format)
uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums)
uint16_t* rowCount = nullptr; // Count of source pixels accumulated per output X
int currentOutY = 0; // Current output row being accumulated
uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point)
if (needsScaling) {
rowAccum = new uint32_t[outWidth]();
rowCount = new uint16_t[outWidth]();
nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1)
}
// Process MCUs row-by-row and write to BMP as we go (top-down) // Process MCUs row-by-row and write to BMP as we go (top-down)
const int mcuPixelWidth = imageInfo.m_MCUWidth; const int mcuPixelWidth = imageInfo.m_MCUWidth;
@ -181,75 +570,164 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
return false; return false;
} }
// Process MCU block into MCU row buffer // picojpeg stores MCU data in 8x8 blocks
// MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks: // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128
// Block layout for 16x16 MCU: [0, 64] (top row of blocks)
// [128, 192] (bottom row of blocks)
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX; const int pixelX = mcuX * mcuPixelWidth + blockX;
if (pixelX >= imageInfo.m_width) continue;
// Skip pixels outside image width (can happen with MCU alignment) // Calculate proper block offset for picojpeg buffer
if (pixelX >= imageInfo.m_width) { const int blockCol = blockX / 8;
continue; const int blockRow = blockY / 8;
} const int localX = blockX % 8;
const int localY = blockY % 8;
const int blocksPerRow = mcuPixelWidth / 8;
const int blockIndex = blockRow * blocksPerRow + blockCol;
const int pixelOffset = blockIndex * 64 + localY * 8 + localX;
// Calculate which 8x8 block and position within that block
const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU
const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU
const int pixelInBlockX = blockX % 8;
const int pixelInBlockY = blockY % 8;
// Calculate byte offset: each 8x8 block is 64 bytes
// Blocks are arranged: [0, 64], [128, 192]
const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64;
const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX;
// Get grayscale value
uint8_t gray; uint8_t gray;
if (imageInfo.m_comps == 1) { if (imageInfo.m_comps == 1) {
// Grayscale image gray = imageInfo.m_pMCUBufR[pixelOffset];
gray = imageInfo.m_pMCUBufR[mcuIndex];
} else { } else {
// RGB image - convert to grayscale const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset];
const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex]; const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset];
const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex]; const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset];
const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex]; gray = (r * 25 + g * 50 + b * 25) / 100;
// Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B
// Using integer approximation: (30*R + 59*G + 11*B) / 100
gray = (r * 30 + g * 59 + b * 11) / 100;
} }
// Store grayscale value in MCU row buffer
mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
} }
} }
} }
// Write all pixel rows from this MCU row to BMP file // Process source rows from this MCU row
const int startRow = mcuY * mcuPixelHeight; const int startRow = mcuY * mcuPixelHeight;
const int endRow = (mcuY + 1) * mcuPixelHeight; const int endRow = (mcuY + 1) * mcuPixelHeight;
for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
memset(rowBuffer, 0, bytesPerRow); const int bufferY = y - startRow;
// Pack 4 pixels per byte (2 bits each) if (!needsScaling) {
for (int x = 0; x < imageInfo.m_width; x++) { // No scaling - direct output (1:1 mapping)
const int bufferY = y - startRow; memset(rowBuffer, 0, bytesPerRow);
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t twoBit = grayscaleTo2Bit(gray);
const int byteIndex = (x * 2) / 8; if (USE_8BIT_OUTPUT) {
const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0 for (int x = 0; x < outWidth; x++) {
rowBuffer[byteIndex] |= (twoBit << bitOffset); const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
} else {
twoBit = quantize(gray, x, y);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
} else {
// Fixed-point area averaging for exact fit scaling
// For each output pixel X, accumulate source pixels that map to it
// srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16)
const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width;
for (int outX = 0; outX < outWidth; outX++) {
// Calculate source X range for this output pixel
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
// Accumulate all source pixels in this range
int sum = 0;
int count = 0;
for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) {
sum += srcRow[srcX];
count++;
}
// Handle edge case: if no pixels in range, use nearest
if (count == 0 && srcXStart < imageInfo.m_width) {
sum = srcRow[srcXStart];
count = 1;
}
rowAccum[outX] += sum;
rowCount[outX] += count;
}
// Check if we've crossed into the next output row
// Current source Y in fixed point: y << 16
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
// Output row when source Y crosses the boundary
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
} else {
twoBit = quantize(gray, x, currentOutY);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
currentOutY++;
// Reset accumulators for next output row
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
memset(rowCount, 0, outWidth * sizeof(uint16_t));
// Update boundary for next output row
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
}
} }
// Write row with padding
bmpOut.write(rowBuffer, bytesPerRow);
} }
} }
// Clean up // Clean up
if (rowAccum) {
delete[] rowAccum;
}
if (rowCount) {
delete[] rowCount;
}
if (atkinsonDitherer) {
delete atkinsonDitherer;
}
if (fsDitherer) {
delete fsDitherer;
}
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);

View File

@ -6,7 +6,7 @@ class ZipFile;
class JpegToBmpConverter { class JpegToBmpConverter {
static void writeBmpHeader(Print& bmpOut, int width, int height); static void writeBmpHeader(Print& bmpOut, int width, int height);
static uint8_t grayscaleTo2Bit(uint8_t grayscale); // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data); unsigned char* pBytes_actually_read, void* pCallback_data);

40
lib/Xtc/README Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,316 @@
/**
* XtcParser.cpp
*
* XTC file parsing implementation
* XTC ebook support for CrossPoint Reader
*/
#include "XtcParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <cstring>
namespace xtc {
XtcParser::XtcParser()
: m_isOpen(false),
m_defaultWidth(DISPLAY_WIDTH),
m_defaultHeight(DISPLAY_HEIGHT),
m_bitDepth(1),
m_lastError(XtcError::OK) {
memset(&m_header, 0, sizeof(m_header));
}
XtcParser::~XtcParser() { close(); }
XtcError XtcParser::open(const char* filepath) {
// Close if already open
if (m_isOpen) {
close();
}
// Open file
if (!FsHelpers::openFileForRead("XTC", filepath, m_file)) {
m_lastError = XtcError::FILE_NOT_FOUND;
return m_lastError;
}
// Read header
m_lastError = readHeader();
if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError));
m_file.close();
return m_lastError;
}
// Read title if available
readTitle();
// Read page table
m_lastError = readPageTable();
if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError));
m_file.close();
return m_lastError;
}
m_isOpen = true;
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount,
m_defaultWidth, m_defaultHeight);
return XtcError::OK;
}
void XtcParser::close() {
if (m_isOpen) {
m_file.close();
m_isOpen = false;
}
m_pageTable.clear();
m_title.clear();
memset(&m_header, 0, sizeof(m_header));
}
XtcError XtcParser::readHeader() {
// Read first 56 bytes of header
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&m_header), sizeof(XtcHeader));
if (bytesRead != sizeof(XtcHeader)) {
return XtcError::READ_ERROR;
}
// Verify magic number (accept both XTC and XTCH)
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic,
XTC_MAGIC, XTCH_MAGIC);
return XtcError::INVALID_MAGIC;
}
// Determine bit depth from file magic
m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1;
// Check version
if (m_header.version > 1) {
Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version);
return XtcError::INVALID_VERSION;
}
// Basic validation
if (m_header.pageCount == 0) {
return XtcError::CORRUPTED_HEADER;
}
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, m_header.pageCount, m_bitDepth);
return XtcError::OK;
}
XtcError XtcParser::readTitle() {
// Title is usually at offset 0x38 (56) for 88-byte headers
// Read title as null-terminated UTF-8 string
if (m_header.titleOffset == 0) {
m_header.titleOffset = 0x38; // Default offset
}
if (!m_file.seek(m_header.titleOffset)) {
return XtcError::READ_ERROR;
}
char titleBuf[128] = {0};
m_file.read(reinterpret_cast<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
m_title = titleBuf;
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
return XtcError::OK;
}
XtcError XtcParser::readPageTable() {
if (m_header.pageTableOffset == 0) {
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
return XtcError::CORRUPTED_HEADER;
}
// Seek to page table
if (!m_file.seek(m_header.pageTableOffset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset);
return XtcError::READ_ERROR;
}
m_pageTable.resize(m_header.pageCount);
// Read page table entries
for (uint16_t i = 0; i < m_header.pageCount; i++) {
PageTableEntry entry;
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
if (bytesRead != sizeof(PageTableEntry)) {
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i);
return XtcError::READ_ERROR;
}
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
m_pageTable[i].size = entry.dataSize;
m_pageTable[i].width = entry.width;
m_pageTable[i].height = entry.height;
m_pageTable[i].bitDepth = m_bitDepth;
// Update default dimensions from first page
if (i == 0) {
m_defaultWidth = entry.width;
m_defaultHeight = entry.height;
}
}
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount);
return XtcError::OK;
}
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
if (pageIndex >= m_pageTable.size()) {
return false;
}
info = m_pageTable[pageIndex];
return true;
}
size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) {
if (!m_isOpen) {
m_lastError = XtcError::FILE_NOT_FOUND;
return 0;
}
if (pageIndex >= m_header.pageCount) {
m_lastError = XtcError::PAGE_OUT_OF_RANGE;
return 0;
}
const PageInfo& page = m_pageTable[pageIndex];
// Seek to page data
if (!m_file.seek(page.offset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset);
m_lastError = XtcError::READ_ERROR;
return 0;
}
// Read page header (XTG for 1-bit, XTH for 2-bit - same structure)
XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader)) {
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex);
m_lastError = XtcError::READ_ERROR;
return 0;
}
// Verify page magic (XTG for 1-bit, XTH for 2-bit)
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (pageHeader.magic != expectedMagic) {
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex,
pageHeader.magic, expectedMagic);
m_lastError = XtcError::INVALID_MAGIC;
return 0;
}
// Calculate bitmap size based on bit depth
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
size_t bitmapSize;
if (m_bitDepth == 2) {
// XTH: two bit planes, each containing (width * height) bits rounded up to bytes
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Check buffer size
if (bufferSize < bitmapSize) {
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize);
m_lastError = XtcError::MEMORY_ERROR;
return 0;
}
// Read bitmap data
size_t bytesRead = m_file.read(buffer, bitmapSize);
if (bytesRead != bitmapSize) {
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead);
m_lastError = XtcError::READ_ERROR;
return 0;
}
m_lastError = XtcError::OK;
return bytesRead;
}
XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize) {
if (!m_isOpen) {
return XtcError::FILE_NOT_FOUND;
}
if (pageIndex >= m_header.pageCount) {
return XtcError::PAGE_OUT_OF_RANGE;
}
const PageInfo& page = m_pageTable[pageIndex];
// Seek to page data
if (!m_file.seek(page.offset)) {
return XtcError::READ_ERROR;
}
// Read and skip page header (XTG for 1-bit, XTH for 2-bit)
XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) {
return XtcError::READ_ERROR;
}
// Calculate bitmap size based on bit depth
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes
size_t bitmapSize;
if (m_bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Read in chunks
std::vector<uint8_t> chunk(chunkSize);
size_t totalRead = 0;
while (totalRead < bitmapSize) {
size_t toRead = std::min(chunkSize, bitmapSize - totalRead);
size_t bytesRead = m_file.read(chunk.data(), toRead);
if (bytesRead == 0) {
return XtcError::READ_ERROR;
}
callback(chunk.data(), bytesRead, totalRead);
totalRead += bytesRead;
}
return XtcError::OK;
}
bool XtcParser::isValidXtcFile(const char* filepath) {
File file = SD.open(filepath, FILE_READ);
if (!file) {
return false;
}
uint32_t magic = 0;
size_t bytesRead = file.read(reinterpret_cast<uint8_t*>(&magic), sizeof(magic));
file.close();
if (bytesRead != sizeof(magic)) {
return false;
}
return (magic == XTC_MAGIC || magic == XTCH_MAGIC);
}
} // namespace xtc

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

@ -0,0 +1,96 @@
/**
* XtcParser.h
*
* XTC file parsing and page data extraction
* XTC ebook support for CrossPoint Reader
*/
#pragma once
#include <SD.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "XtcTypes.h"
namespace xtc {
/**
* XTC File Parser
*
* Reads XTC files from SD card and extracts page data.
* Designed for ESP32-C3's limited RAM (~380KB) using streaming.
*/
class XtcParser {
public:
XtcParser();
~XtcParser();
// File open/close
XtcError open(const char* filepath);
void close();
bool isOpen() const { return m_isOpen; }
// Header information access
const XtcHeader& getHeader() const { return m_header; }
uint16_t getPageCount() const { return m_header.pageCount; }
uint16_t getWidth() const { return m_defaultWidth; }
uint16_t getHeight() const { return m_defaultHeight; }
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
// Page information
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
/**
* Load page bitmap (raw 1-bit data, skipping XTG header)
*
* @param pageIndex Page index (0-based)
* @param buffer Output buffer (caller allocated)
* @param bufferSize Buffer size
* @return Number of bytes read on success, 0 on failure
*/
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize);
/**
* Streaming page load
* Memory-efficient method that reads page data in chunks.
*
* @param pageIndex Page index
* @param callback Callback function to receive data chunks
* @param chunkSize Chunk size (default: 1024 bytes)
* @return Error code
*/
XtcError loadPageStreaming(uint32_t pageIndex,
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize = 1024);
// Get title from metadata
std::string getTitle() const { return m_title; }
// Validation
static bool isValidXtcFile(const char* filepath);
// Error information
XtcError getLastError() const { return m_lastError; }
private:
File m_file;
bool m_isOpen;
XtcHeader m_header;
std::vector<PageInfo> m_pageTable;
std::string m_title;
uint16_t m_defaultWidth;
uint16_t m_defaultHeight;
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
XtcError m_lastError;
// Internal helper functions
XtcError readHeader();
XtcError readPageTable();
XtcError readTitle();
};
} // namespace xtc

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

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

View File

@ -1,5 +1,5 @@
[platformio] [platformio]
crosspoint_version = 0.9.0 crosspoint_version = 0.10.0
default_envs = default default_envs = default
[base] [base]

View File

@ -10,7 +10,8 @@ CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 3; // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 5;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -28,6 +29,8 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, sleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, shortPwrBtn); serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, statusBar);
serialization::writePod(outputFile, orientation);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -51,7 +54,7 @@ bool CrossPointSettings::loadFromFile() {
uint8_t fileSettingsCount = 0; uint8_t fileSettingsCount = 0;
serialization::readPod(inputFile, fileSettingsCount); serialization::readPod(inputFile, fileSettingsCount);
// load settings that exist // load settings that exist (support older files with fewer fields)
uint8_t settingsRead = 0; uint8_t settingsRead = 0;
do { do {
serialization::readPod(inputFile, sleepScreen); serialization::readPod(inputFile, sleepScreen);
@ -60,6 +63,10 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, shortPwrBtn); serialization::readPod(inputFile, shortPwrBtn);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, statusBar);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, orientation);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
inputFile.close(); inputFile.close();

View File

@ -18,12 +18,27 @@ class CrossPointSettings {
// Should match with SettingsActivity text // Should match with SettingsActivity text
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 };
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 };
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
INVERTED = 2, // 480x800 logical coordinates, inverted
LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation
};
// Sleep screen settings // Sleep screen settings
uint8_t sleepScreen = DARK; uint8_t sleepScreen = DARK;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings // Text rendering settings
uint8_t extraParagraphSpacing = 1; uint8_t extraParagraphSpacing = 1;
// Duration of the power button press // Duration of the power button press
uint8_t shortPwrBtn = 0; uint8_t shortPwrBtn = 0;
// EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT;
~CrossPointSettings() = default; ~CrossPointSettings() = default;

View File

@ -8,11 +8,11 @@
void BootActivity::onEnter() { void BootActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen(); renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING");
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);

View File

@ -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...");
@ -112,7 +127,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen(); renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
@ -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);

View File

@ -56,7 +56,7 @@ void HomeActivity::loop() {
const int menuCount = getMenuItemCount(); const int menuCount = getMenuItemCount();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) {
if (hasContinueReading) { if (hasContinueReading) {
// Menu: Continue Reading, Browse, File transfer, Settings // Menu: Continue Reading, Browse, File transfer, Settings
if (selectorIndex == 0) { if (selectorIndex == 0) {
@ -106,7 +106,7 @@ void HomeActivity::render() const {
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection // Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + selectorIndex * 30 - 2, pageWidth - 1, 30);
int menuY = 60; int menuY = 60;
int menuIndex = 0; int menuIndex = 0;

View File

@ -187,12 +187,25 @@ void WifiSelectionActivity::selectNetwork(const int index) {
if (selectedRequiresPassword) { if (selectedRequiresPassword) {
// Show password entry // Show password entry
state = WifiSelectionState::PASSWORD_ENTRY; state = WifiSelectionState::PASSWORD_ENTRY;
enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", // Don't allow screen updates while changing activity
"", // No initial text xSemaphoreTake(renderingMutex, portMAX_DELAY);
64, // Max password length enterNewActivity(new KeyboardEntryActivity(
false // Show password by default (hard keyboard to use) renderer, inputManager, "Enter WiFi Password",
)); "", // No initial text
50, // Y position
64, // Max password length
false, // Show password by default (hard keyboard to use)
[this](const std::string& text) {
enteredPassword = text;
exitActivity();
},
[this] {
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
exitActivity();
}));
updateRequired = true; updateRequired = true;
xSemaphoreGive(renderingMutex);
} else { } else {
// Connect directly for open networks // Connect directly for open networks
attemptConnection(); attemptConnection();
@ -208,11 +221,6 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (subActivity && !usedSavedPassword) {
enteredPassword = static_cast<KeyboardEntryActivity*>(subActivity.get())->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) { if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else { } else {
@ -269,6 +277,11 @@ void WifiSelectionActivity::checkConnectionStatus() {
} }
void WifiSelectionActivity::loop() { void WifiSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Check scan progress // Check scan progress
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
processWifiScanResults(); processWifiScanResults();
@ -281,24 +294,9 @@ void WifiSelectionActivity::loop() {
return; return;
} }
// Handle password entry state if (state == WifiSelectionState::PASSWORD_ENTRY) {
if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) { // Reach here once password entry finished in subactivity
const auto keyboard = static_cast<KeyboardEntryActivity*>(subActivity.get()); attemptConnection();
keyboard->handleInput();
if (keyboard->isComplete()) {
attemptConnection();
return;
}
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
exitActivity();
updateRequired = true;
return;
}
updateRequired = true;
return; return;
} }
@ -441,6 +439,10 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
void WifiSelectionActivity::displayTaskLoop() { void WifiSelectionActivity::displayTaskLoop() {
while (true) { while (true) {
if (subActivity) {
continue;
}
if (updateRequired) { if (updateRequired) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -461,9 +463,6 @@ void WifiSelectionActivity::render() const {
case WifiSelectionState::NETWORK_LIST: case WifiSelectionState::NETWORK_LIST:
renderNetworkList(); renderNetworkList();
break; break;
case WifiSelectionState::PASSWORD_ENTRY:
renderPasswordEntry();
break;
case WifiSelectionState::CONNECTING: case WifiSelectionState::CONNECTING:
renderConnecting(); renderConnecting();
break; break;
@ -561,23 +560,6 @@ void WifiSelectionActivity::renderNetworkList() const {
renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", "");
} }
void WifiSelectionActivity::renderPasswordEntry() const {
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo.replace(27, networkInfo.length() - 27, "...");
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (subActivity) {
static_cast<KeyboardEntryActivity*>(subActivity.get())->render(58);
}
}
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);

View File

@ -16,10 +16,8 @@ constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000; constexpr unsigned long goHomeMs = 1000;
constexpr float lineCompression = 0.95f; constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8; constexpr int horizontalPadding = 5;
constexpr int marginRight = 10; constexpr int statusBarMargin = 19;
constexpr int marginBottom = 22;
constexpr int marginLeft = 10;
} // namespace } // namespace
void EpubReaderActivity::taskTrampoline(void* param) { void EpubReaderActivity::taskTrampoline(void* param) {
@ -34,6 +32,24 @@ void EpubReaderActivity::onEnter() {
return; return;
} }
// Configure screen orientation based on settings
switch (SETTINGS.orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
epub->setupCacheDir(); epub->setupCacheDir();
@ -67,6 +83,9 @@ void EpubReaderActivity::onEnter() {
void EpubReaderActivity::onExit() { void EpubReaderActivity::onExit() {
ActivityWithSubactivity::onExit(); ActivityWithSubactivity::onExit();
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -87,7 +106,7 @@ void EpubReaderActivity::loop() {
} }
// Enter chapter selection activity // Enter chapter selection activity
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) {
// Don't start activity transition while rendering // Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
@ -219,31 +238,70 @@ void EpubReaderActivity::renderScreen() {
return; return;
} }
// Apply screen viewable areas and additional padding
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginLeft += horizontalPadding;
orientedMarginRight += horizontalPadding;
orientedMarginBottom += statusBarMargin;
if (!section) { if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href; const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
SETTINGS.extraParagraphSpacing)) { const auto viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const auto viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth,
viewportHeight)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(READER_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{ {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
constexpr int margin = 20; renderer.drawText(READER_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
constexpr int y = 50;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
renderer.fillRect(x, y, w, h, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh = 0; pagesUntilFullRefresh = 0;
} }
section->setupCacheDir(); section->setupCacheDir();
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, SETTINGS.extraParagraphSpacing)) { // Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(READER_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
};
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth,
viewportHeight, progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -264,7 +322,7 @@ void EpubReaderActivity::renderScreen() {
if (section->pageCount == 0) { if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis()); Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(READER_FONT_ID, 300, "Empty chapter", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 300, "Empty chapter", true, BOLD);
renderStatusBar(); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -272,7 +330,7 @@ void EpubReaderActivity::renderScreen() {
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
renderer.drawCenteredText(READER_FONT_ID, 300, "Out of bounds", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 300, "Out of bounds", true, BOLD);
renderStatusBar(); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -286,7 +344,7 @@ void EpubReaderActivity::renderScreen() {
return renderScreen(); return renderScreen();
} }
const auto start = millis(); const auto start = millis();
renderContents(std::move(p)); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
@ -302,9 +360,11 @@ void EpubReaderActivity::renderScreen() {
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
page->render(renderer, READER_FONT_ID); const int orientedMarginRight, const int orientedMarginBottom,
renderStatusBar(); const int orientedMarginLeft) {
page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh; pagesUntilFullRefresh = pagesPerRefresh;
@ -321,13 +381,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
{ {
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, READER_FONT_ID); page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleLsbBuffers(); renderer.copyGrayscaleLsbBuffers();
// Render and copy to MSB buffer // Render and copy to MSB buffer
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, READER_FONT_ID); page->render(renderer, READER_FONT_ID, orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleMsbBuffers(); renderer.copyGrayscaleMsbBuffers();
// display grayscale part // display grayscale part
@ -339,72 +399,90 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
renderer.restoreBwBuffer(); renderer.restoreBwBuffer();
} }
void EpubReaderActivity::renderStatusBar() const { void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
constexpr auto textY = 776; const int orientedMarginLeft) const {
// determine visible status bar elements
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
// Calculate progress in book // Position status bar near the bottom of the logical screen, regardless of orientation
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount; const auto screenHeight = renderer.getScreenHeight();
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); const auto textY = screenHeight - orientedMarginBottom + 2;
int percentageTextWidth = 0;
int progressTextWidth = 0;
// Right aligned text for progress counter if (showProgress) {
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + // Calculate progress in book
" " + std::to_string(bookProgress) + "%"; const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str());
// Left aligned battery icon and percentage // Right aligned text for progress counter
const uint16_t percentage = battery.readPercentage(); const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
const auto percentageText = std::to_string(percentage) + "%"; " " + std::to_string(bookProgress) + "%";
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
progress.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int x = marginLeft;
constexpr int y = 783;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
} }
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
// Centered chatper title text if (showBattery) {
// Page width minus existing content with 30px padding on each side // Left aligned battery icon and percentage
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; const uint16_t percentage = battery.readPercentage();
const int titleMarginRight = progressTextWidth + 30 + marginRight; const auto percentageText = std::to_string(percentage) + "%";
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); renderer.drawText(SMALL_FONT_ID, 20 + orientedMarginLeft, textY, percentageText.c_str());
std::string title; // 1 column on left, 2 columns on right, 5 columns of battery body
int titleWidth; constexpr int batteryWidth = 15;
if (tocIndex == -1) { constexpr int batteryHeight = 10;
title = "Unnamed"; const int x = orientedMarginLeft;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); const int y = screenHeight - orientedMarginBottom + 5;
} else {
const auto tocItem = epub->getTocItem(tocIndex); // Top line
title = tocItem.title; renderer.drawLine(x, y, x + batteryWidth - 4, y);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); // Bottom line
while (titleWidth > availableTextWidth && title.length() > 11) { renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
title.replace(title.length() - 8, 8, "..."); // Left line
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
} }
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
} }
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); if (showChapterTitle) {
// Centered chatper title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 20 + percentageTextWidth + 30 + orientedMarginLeft;
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string title;
int titleWidth;
if (tocIndex == -1) {
title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed");
} else {
const auto tocItem = epub->getTocItem(tocIndex);
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
}
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
}
} }

View File

@ -22,8 +22,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void renderScreen(); void renderScreen();
void renderContents(std::unique_ptr<Page> p); void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
void renderStatusBar() const; int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,

View File

@ -7,10 +7,26 @@
#include "config.h" #include "config.h"
namespace { namespace {
constexpr int PAGE_ITEMS = 24; // Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700; constexpr int SKIP_PAGE_MS = 700;
} // namespace } // namespace
int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen
constexpr int startY = 60;
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY;
int items = availableHeight / lineHeight;
// Ensure we always have at least one item per page to avoid division by zero
if (items < 1) {
items = 1;
}
return items;
}
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param); auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
@ -56,22 +72,23 @@ void EpubReaderChapterSelectionActivity::loop() {
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex); onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (inputManager.wasReleased(InputManager::BTN_BACK)) {
onGoBack(); onGoBack();
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {
selectorIndex = selectorIndex =
((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount(); ((selectorIndex / pageItems - 1) * pageItems + epub->getSpineItemsCount()) % epub->getSpineItemsCount();
} else { } else {
selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount(); selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount();
} }
updateRequired = true; updateRequired = true;
} else if (nextReleased) { } else if (nextReleased) {
if (skipPage) { if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount(); selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getSpineItemsCount();
} else { } else {
selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount(); selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount();
} }
@ -95,17 +112,18 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD);
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + PAGE_ITEMS; i++) { for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + pageItems; i++) {
const int tocIndex = epub->getTocIndexForSpineIndex(i); const int tocIndex = epub->getTocIndexForSpineIndex(i);
if (tocIndex == -1) { if (tocIndex == -1) {
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex); renderer.drawText(UI_FONT_ID, 20, 60 + (i % pageItems) * 30, "Unnamed", i != selectorIndex);
} else { } else {
auto item = epub->getTocItem(tocIndex); auto item = epub->getTocItem(tocIndex);
renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % PAGE_ITEMS) * 30, item.title.c_str(), renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % pageItems) * 30, item.title.c_str(),
i != selectorIndex); i != selectorIndex);
} }
} }

View File

@ -18,6 +18,10 @@ class EpubReaderChapterSelectionActivity final : public Activity {
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex)> onSelectSpineIndex; const std::function<void(int newSpineIndex)> onSelectSpineIndex;
// Number of items that fit on a page, derived from logical screen height.
// This adapts automatically when switching between portrait and landscape.
int getPageItems() const;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void renderScreen(); void renderScreen();

View File

@ -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();
} }
@ -101,7 +105,7 @@ void FileSelectionActivity::loop() {
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) {
if (files.empty()) { if (files.empty()) {
return; return;
} }
@ -158,20 +162,20 @@ void FileSelectionActivity::displayTaskLoop() {
void FileSelectionActivity::render() const { void FileSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
// Help text // Help text
renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); 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;
} }
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
auto item = files[i]; auto item = files[i];
int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str()); int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());

View File

@ -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));
}
} }

View File

@ -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;
}; };

View 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();
}
}

View 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;
};

View File

@ -9,12 +9,17 @@
// Define the static settings list // Define the static settings list
namespace { namespace {
constexpr int settingsCount = 4; constexpr int settingsCount = 6;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
{"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}},
{"Reading Orientation",
SettingType::ENUM,
&CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}},
{"Check for updates", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}},
}; };
} // namespace } // namespace
@ -138,8 +143,8 @@ void SettingsActivity::displayTaskLoop() {
void SettingsActivity::render() const { void SettingsActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header // Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "Settings", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Settings", true, BOLD);

View File

@ -8,7 +8,7 @@ void FullScreenMessageActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (GfxRenderer::getScreenHeight() - height) / 2; const auto top = (renderer.getScreenHeight() - height) / 2;
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_FONT_ID, top, text.c_str(), true, style); renderer.drawCenteredText(UI_FONT_ID, top, text.c_str(), true, style);

View File

@ -10,41 +10,55 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
// Keyboard layouts - uppercase/symbols // Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"}; "ZXCVBNM<>?", "SPECIAL ROW"};
void KeyboardEntryActivity::setText(const std::string& newText) { void KeyboardEntryActivity::taskTrampoline(void* param) {
text = newText; auto* self = static_cast<KeyboardEntryActivity*>(param);
if (maxLength > 0 && text.length() > maxLength) { self->displayTaskLoop();
text.resize(maxLength);
}
} }
void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) { void KeyboardEntryActivity::displayTaskLoop() {
if (!newTitle.empty()) { while (true) {
title = newTitle; if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
} }
text = newInitialText;
selectedRow = 0;
selectedCol = 0;
shiftActive = false;
complete = false;
cancelled = false;
} }
void KeyboardEntryActivity::onEnter() { void KeyboardEntryActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
// Reset state when entering the activity renderingMutex = xSemaphoreCreateMutex();
complete = false;
cancelled = false; // Trigger first update
updateRequired = true;
xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
} }
void KeyboardEntryActivity::loop() { void KeyboardEntryActivity::onExit() {
handleInput(); Activity::onExit();
render(10);
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
} }
int KeyboardEntryActivity::getRowLength(int row) const { int KeyboardEntryActivity::getRowLength(const int row) const {
if (row < 0 || row >= NUM_ROWS) return 0; if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout // Return actual length of each row based on keyboard layout
@ -58,7 +72,7 @@ int KeyboardEntryActivity::getRowLength(int row) const {
case 3: case 3:
return 10; // zxcvbnm,./ return 10; // zxcvbnm,./
case 4: case 4:
return 10; // ^, space (5 wide), backspace, OK (2 wide) return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK
default: default:
return 0; return 0;
} }
@ -75,8 +89,8 @@ char KeyboardEntryActivity::getSelectedChar() const {
void KeyboardEntryActivity::handleKeyPress() { void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done) // Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SHIFT_ROW) { if (selectedRow == SPECIAL_ROW) {
if (selectedCol == SHIFT_COL) { if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// Shift toggle // Shift toggle
shiftActive = !shiftActive; shiftActive = !shiftActive;
return; return;
@ -90,7 +104,7 @@ void KeyboardEntryActivity::handleKeyPress() {
return; return;
} }
if (selectedCol == BACKSPACE_COL) { if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// Backspace // Backspace
if (!text.empty()) { if (!text.empty()) {
text.pop_back(); text.pop_back();
@ -100,7 +114,6 @@ void KeyboardEntryActivity::handleKeyPress() {
if (selectedCol >= DONE_COL) { if (selectedCol >= DONE_COL) {
// Done button // Done button
complete = true;
if (onComplete) { if (onComplete) {
onComplete(text); onComplete(text);
} }
@ -109,42 +122,61 @@ void KeyboardEntryActivity::handleKeyPress() {
} }
// Regular character // Regular character
char c = getSelectedChar(); const char c = getSelectedChar();
if (c != '\0' && c != '^' && c != '_' && c != '<') { if (c == '\0') {
if (maxLength == 0 || text.length() < maxLength) { return;
text += c; }
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { if (maxLength == 0 || text.length() < maxLength) {
shiftActive = false; text += c;
} // Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
} }
} }
} }
bool KeyboardEntryActivity::handleInput() { void KeyboardEntryActivity::loop() {
if (complete || cancelled) {
return false;
}
bool handled = false;
// Navigation // Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) { if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (selectedRow > 0) { if (selectedRow > 0) {
selectedRow--; selectedRow--;
// Clamp column to valid range for new row // Clamp column to valid range for new row
int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN)) { }
if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedRow < NUM_ROWS - 1) { if (selectedRow < NUM_ROWS - 1) {
selectedRow++; selectedRow++;
int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_LEFT)) { }
if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) { if (selectedCol > 0) {
selectedCol--; selectedCol--;
} else if (selectedRow > 0) { } else if (selectedRow > 0) {
@ -152,9 +184,31 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow--; selectedRow--;
selectedCol = getRowLength(selectedRow) - 1; selectedCol = getRowLength(selectedRow) - 1;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { }
int maxCol = getRowLength(selectedRow) - 1;
if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to backspace
selectedCol = BACKSPACE_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) { if (selectedCol < maxCol) {
selectedCol++; selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) { } else if (selectedRow < NUM_ROWS - 1) {
@ -162,35 +216,34 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow++; selectedRow++;
selectedCol = 0; selectedCol = 0;
} }
handled = true; updateRequired = true;
} }
// Selection // Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
handleKeyPress(); handleKeyPress();
handled = true; updateRequired = true;
} }
// Cancel // Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (inputManager.wasPressed(InputManager::BTN_BACK)) {
cancelled = true;
if (onCancel) { if (onCancel) {
onCancel(); onCancel();
} }
handled = true; updateRequired = true;
} }
return handled;
} }
void KeyboardEntryActivity::render(int startY) const { void KeyboardEntryActivity::render() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
// Draw title // Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
// Draw input field // Draw input field
int inputY = startY + 22; const int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "["); renderer.drawText(UI_FONT_ID, 10, inputY, "[");
std::string displayText; std::string displayText;
@ -204,9 +257,9 @@ void KeyboardEntryActivity::render(int startY) const {
displayText += "_"; displayText += "_";
// Truncate if too long for display - use actual character width from font // Truncate if too long for display - use actual character width from font
int charWidth = renderer.getSpaceWidth(UI_FONT_ID); int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (charWidth < 1) charWidth = 8; // Fallback to approximate width if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width
int maxDisplayLen = (pageWidth - 40) / charWidth; const int maxDisplayLen = (pageWidth - 40) / approxCharWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) { if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
} }
@ -215,22 +268,22 @@ void KeyboardEntryActivity::render(int startY) const {
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen // Draw keyboard - use compact spacing to fit 5 rows on screen
int keyboardStartY = inputY + 25; const int keyboardStartY = inputY + 25;
const int keyWidth = 18; constexpr int keyWidth = 18;
const int keyHeight = 18; constexpr int keyHeight = 18;
const int keySpacing = 3; constexpr int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard; const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys) // Calculate left margin to center the longest row (13 keys)
int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
int leftMargin = (pageWidth - maxRowWidth) / 2; const int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) { for (int row = 0; row < NUM_ROWS; row++) {
int rowY = keyboardStartY + row * (keyHeight + keySpacing); const int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation // Left-align all rows for consistent navigation
int startX = leftMargin; const int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys // Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) { if (row == 4) {
@ -240,69 +293,53 @@ void KeyboardEntryActivity::render(int startY) const {
int currentX = startX; int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths) // CAPS key (logical col 0, spans 2 key widths)
int capsWidth = 2 * keyWidth + keySpacing; const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL); renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected);
if (capsSelected) { currentX += 2 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps");
currentX += capsWidth + keySpacing;
// Space bar (logical cols 2-6, spans 5 key widths) // Space bar (logical cols 2-6, spans 5 key widths)
int spaceWidth = 5 * keyWidth + 4 * keySpacing; const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____");
if (spaceSelected) { const int spaceXWidth = 5 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2;
renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]"); renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected);
} currentX += spaceXWidth;
// Draw centered underscores for space bar
int spaceTextX = currentX + (spaceWidth / 2) - 12;
renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____");
currentX += spaceWidth + keySpacing;
// Backspace key (logical col 7, spans 2 key widths) // Backspace key (logical col 7, spans 2 key widths)
int bsWidth = 2 * keyWidth + keySpacing; const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL);
bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL); renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected);
if (bsSelected) { currentX += 2 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-");
currentX += bsWidth + keySpacing;
// OK button (logical col 9, spans 2 key widths) // OK button (logical col 9, spans 2 key widths)
int okWidth = 2 * keyWidth + keySpacing; const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); renderItemWithSelector(currentX + 2, rowY, "OK", okSelected);
if (okSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK");
} else { } else {
// Regular rows: render each key individually // Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) { for (int col = 0; col < getRowLength(row); col++) {
int keyX = startX + col * (keyWidth + keySpacing);
// Get the character to display // Get the character to display
char c = layout[row][col]; const char c = layout[row][col];
std::string keyLabel(1, c); std::string keyLabel(1, c);
const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str());
// Draw selection highlight const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2;
bool isSelected = (row == selectedRow && col == selectedCol); const bool isSelected = row == selectedRow && col == selectedCol;
renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected);
if (isSelected) {
renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str());
} }
} }
} }
// Draw help text at absolute bottom of screen (consistent with other screens) // Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
renderer.displayBuffer();
}
void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item,
const bool isSelected) const {
if (isSelected) {
const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item);
renderer.drawText(UI_FONT_ID, x - 6, y, "[");
renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]");
}
renderer.drawText(UI_FONT_ID, x, y, item);
} }

View File

@ -1,9 +1,13 @@
#pragma once #pragma once
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional> #include <functional>
#include <string> #include <string>
#include <utility>
#include "../Activity.h" #include "../Activity.h"
@ -30,80 +34,44 @@ class KeyboardEntryActivity : public Activity {
* @param inputManager Reference to InputManager for handling input * @param inputManager Reference to InputManager for handling input
* @param title Title to display above the keyboard * @param title Title to display above the keyboard
* @param initialText Initial text to show in the input field * @param initialText Initial text to show in the input field
* @param startY Y position to start rendering the keyboard
* @param maxLength Maximum length of input text (0 for unlimited) * @param maxLength Maximum length of input text (0 for unlimited)
* @param isPassword If true, display asterisks instead of actual characters * @param isPassword If true, display asterisks instead of actual characters
* @param onComplete Callback invoked when input is complete
* @param onCancel Callback invoked when input is cancelled
*/ */
KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text",
const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false) std::string initialText = "", const int startY = 10, const size_t maxLength = 0,
const bool isPassword = false, OnCompleteCallback onComplete = nullptr,
OnCancelCallback onCancel = nullptr)
: Activity("KeyboardEntry", renderer, inputManager), : Activity("KeyboardEntry", renderer, inputManager),
title(title), title(std::move(title)),
text(initialText), text(std::move(initialText)),
startY(startY),
maxLength(maxLength), maxLength(maxLength),
isPassword(isPassword) {} isPassword(isPassword),
onComplete(std::move(onComplete)),
/** onCancel(std::move(onCancel)) {}
* Handle button input. Call this in your main loop.
* @return true if input was handled, false otherwise
*/
bool handleInput();
/**
* Render the keyboard at the specified Y position.
* @param startY Y-coordinate where keyboard rendering starts (default 10)
*/
void render(int startY = 10) const;
/**
* Get the current text entered by the user.
*/
const std::string& getText() const { return text; }
/**
* Set the current text.
*/
void setText(const std::string& newText);
/**
* Check if the user has completed text entry (pressed OK on Done).
*/
bool isComplete() const { return complete; }
/**
* Check if the user has cancelled text entry.
*/
bool isCancelled() const { return cancelled; }
/**
* Reset the keyboard state for reuse.
*/
void reset(const std::string& newTitle = "", const std::string& newInitialText = "");
/**
* Set callback for when input is complete.
*/
void setOnComplete(OnCompleteCallback callback) { onComplete = callback; }
/**
* Set callback for when input is cancelled.
*/
void setOnCancel(OnCancelCallback callback) { onCancel = callback; }
// Activity overrides // Activity overrides
void onEnter() override; void onEnter() override;
void onExit() override;
void loop() override; void loop() override;
private: private:
std::string title; std::string title;
int startY;
std::string text; std::string text;
size_t maxLength; size_t maxLength;
bool isPassword; bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
// Keyboard state // Keyboard state
int selectedRow = 0; int selectedRow = 0;
int selectedCol = 0; int selectedCol = 0;
bool shiftActive = false; bool shiftActive = false;
bool complete = false;
bool cancelled = false;
// Callbacks // Callbacks
OnCompleteCallback onComplete; OnCompleteCallback onComplete;
@ -116,16 +84,17 @@ class KeyboardEntryActivity : public Activity {
static const char* const keyboardShift[NUM_ROWS]; static const char* const keyboardShift[NUM_ROWS];
// Special key positions (bottom row) // Special key positions (bottom row)
static constexpr int SHIFT_ROW = 4; static constexpr int SPECIAL_ROW = 4;
static constexpr int SHIFT_COL = 0; static constexpr int SHIFT_COL = 0;
static constexpr int SPACE_ROW = 4;
static constexpr int SPACE_COL = 2; static constexpr int SPACE_COL = 2;
static constexpr int BACKSPACE_ROW = 4;
static constexpr int BACKSPACE_COL = 7; static constexpr int BACKSPACE_COL = 7;
static constexpr int DONE_ROW = 4;
static constexpr int DONE_COL = 9; static constexpr int DONE_COL = 9;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
char getSelectedChar() const; char getSelectedChar() const;
void handleKeyPress(); void handleKeyPress();
int getRowLength(int row) const; int getRowLength(int row) const;
void render() const;
void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const;
}; };

View File

@ -27,7 +27,12 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
} }
JsonDocument doc; JsonDocument doc;
const DeserializationError error = deserializeJson(doc, *client); JsonDocument filter;
filter["tag_name"] = true;
filter["assets"][0]["name"] = true;
filter["assets"][0]["browser_download_url"] = true;
filter["assets"][0]["size"] = true;
const DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter));
http.end(); http.end();
if (error) { if (error) {
Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str());