From 98f14da65ac249990a41a8a930cea2feb36b6b16 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 4 Jan 2026 21:19:30 +0900 Subject: [PATCH 1/5] Add TXT file reader support - Add Txt library for loading and parsing plain text files - Create TxtReaderActivity with streaming page rendering - Uses 8KB chunks to handle large files without memory issues - Page index caching for fast re-open after sleep - Progress bar during initial indexing - Word wrapping with UTF-8 support - Support cover images for TXT files - Priority: same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in same folder - Converts JPG to BMP using existing converter - Update SleepActivity to show TXT cover images in Cover mode - Add .txt extension to file browser filter --- lib/Txt/Txt.cpp | 189 ++++++ lib/Txt/Txt.h | 33 + src/activities/boot_sleep/SleepActivity.cpp | 24 +- .../reader/FileSelectionActivity.cpp | 2 +- src/activities/reader/ReaderActivity.cpp | 51 ++ src/activities/reader/ReaderActivity.h | 4 + src/activities/reader/TxtReaderActivity.cpp | 622 ++++++++++++++++++ src/activities/reader/TxtReaderActivity.h | 54 ++ 8 files changed, 977 insertions(+), 2 deletions(-) create mode 100644 lib/Txt/Txt.cpp create mode 100644 lib/Txt/Txt.h create mode 100644 src/activities/reader/TxtReaderActivity.cpp create mode 100644 src/activities/reader/TxtReaderActivity.h diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp new file mode 100644 index 00000000..3c8b37eb --- /dev/null +++ b/lib/Txt/Txt.cpp @@ -0,0 +1,189 @@ +#include "Txt.h" + +#include +#include + +Txt::Txt(std::string path, std::string cacheBasePath) : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { + // Generate cache path from file path hash + const size_t hash = std::hash{}(filepath); + cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash); +} + +bool Txt::load() { + if (loaded) { + return true; + } + + if (!SdMan.exists(filepath.c_str())) { + Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); + return false; + } + + fileSize = file.size(); + file.close(); + + loaded = true; + Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); + return true; +} + +std::string Txt::getTitle() const { + // Extract filename without path and extension + size_t lastSlash = filepath.find_last_of('/'); + std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; + + // Remove .txt extension + if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") { + filename = filename.substr(0, filename.length() - 4); + } + + return filename; +} + +void Txt::setupCacheDir() const { + if (!SdMan.exists(cacheBasePath.c_str())) { + SdMan.mkdir(cacheBasePath.c_str()); + } + if (!SdMan.exists(cachePath.c_str())) { + SdMan.mkdir(cachePath.c_str()); + } +} + +std::string Txt::findCoverImage() const { + // Get the folder containing the txt file + size_t lastSlash = filepath.find_last_of('/'); + std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : ""; + if (folder.empty()) { + folder = "/"; + } + + // Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt") + std::string baseName = getTitle(); + + // Image extensions to try + const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"}; + + // First priority: look for image with same name as txt file (e.g., mybook.jpg) + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + baseName + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + + // Fallback: look for cover image files + const char* coverNames[] = {"cover", "Cover", "COVER"}; + for (const auto& name : coverNames) { + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + std::string(name) + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + } + + return ""; +} + +std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Txt::generateCoverBmp() const { + // Already generated, return true + if (SdMan.exists(getCoverBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get file extension + const size_t len = coverImagePath.length(); + const bool isJpg = (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || + (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); + const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP"); + + if (isBmp) { + // Copy BMP file to cache + Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); + FsFile src, dst; + if (!SdMan.openFileForRead("TXT", coverImagePath, src)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) { + src.close(); + return false; + } + uint8_t buffer[1024]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + src.close(); + dst.close(); + Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); + return true; + } + + if (isJpg) { + // Convert JPG/JPEG to BMP (same approach as Epub) + Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); + FsFile coverJpg, coverBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); + SdMan.remove(getCoverBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); + } + return success; + } + + // PNG files are not supported (would need a PNG decoder) + Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); + return false; +} + +bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { + if (!loaded) { + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + return false; + } + + if (!file.seek(offset)) { + file.close(); + return false; + } + + size_t bytesRead = file.read(buffer, length); + file.close(); + + return bytesRead > 0; +} diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h new file mode 100644 index 00000000..b75c7738 --- /dev/null +++ b/lib/Txt/Txt.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include + +class Txt { + std::string filepath; + std::string cacheBasePath; + std::string cachePath; + bool loaded = false; + size_t fileSize = 0; + + public: + explicit Txt(std::string path, std::string cacheBasePath); + + bool load(); + [[nodiscard]] const std::string& getPath() const { return filepath; } + [[nodiscard]] const std::string& getCachePath() const { return cachePath; } + [[nodiscard]] std::string getTitle() const; + [[nodiscard]] size_t getFileSize() const { return fileSize; } + + void setupCacheDir() const; + + // Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file + [[nodiscard]] std::string getCoverBmpPath() const; + [[nodiscard]] bool generateCoverBmp() const; + [[nodiscard]] std::string findCoverImage() const; + + // Read content from file + [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; +}; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index cc9fa9d9..eaf90276 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "CrossPointSettings.h" @@ -22,6 +23,13 @@ bool isXtcFile(const std::string& path) { } return false; } + +// Check if path has TXT extension +bool isTxtFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + return ext4 == ".txt" || ext4 == ".TXT"; +} } // namespace void SleepActivity::onEnter() { @@ -192,7 +200,7 @@ void SleepActivity::renderCoverSleepScreen() const { std::string coverBmpPath; - // Check if the current book is XTC or EPUB + // Check if the current book is XTC, TXT, or EPUB if (isXtcFile(APP_STATE.openEpubPath)) { // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); @@ -207,6 +215,20 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); + } else if (isTxtFile(APP_STATE.openEpubPath)) { + // Handle TXT file - looks for cover image in the same folder + Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastTxt.load()) { + Serial.println("[SLP] Failed to load last TXT"); + return renderDefaultSleepScreen(); + } + + if (!lastTxt.generateCoverBmp()) { + Serial.println("[SLP] No cover image found for TXT file"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastTxt.getCoverBmpPath(); } else { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index f87cc97c..56e3ee9b 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -53,7 +53,7 @@ void FileSelectionActivity::loadFiles() { auto filename = std::string(name); 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") { + if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc" || ext4 == ".txt") { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 1829218a..38d479dc 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -3,6 +3,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "Txt.h" +#include "TxtReaderActivity.h" #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" @@ -26,6 +28,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) { return false; } +bool ReaderActivity::isTxtFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + return ext4 == ".txt" || ext4 == ".TXT"; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SdMan.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -56,6 +64,21 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } +std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); + return nullptr; + } + + auto txt = std::unique_ptr(new Txt(path, "/.crosspoint")); + if (txt->load()) { + return txt; + } + + Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); + return nullptr; +} + void ReaderActivity::onSelectBookFile(const std::string& path) { currentBookPath = path; // Track current book path exitActivity(); @@ -73,6 +96,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) { delay(2000); onGoToFileSelection(); } + } else if (isTxtFile(path)) { + // Load TXT file + auto txt = loadTxt(path); + if (txt) { + onGoToTxtReader(std::move(txt)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT", + EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { // Load EPUB file auto epub = loadEpub(path); @@ -114,6 +149,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { [this] { onGoBack(); })); } +void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + exitActivity(); + enterNewActivity(new TxtReaderActivity( + renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); @@ -131,6 +175,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isTxtFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToTxtReader(std::move(txt)); } else { auto epub = loadEpub(initialBookPath); if (!epub) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index df44afe5..bec2a45b 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -5,6 +5,7 @@ class Epub; class Xtc; +class Txt; class ReaderActivity final : public ActivityWithSubactivity { std::string initialBookPath; @@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); + static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); + static bool isTxtFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void onSelectBookFile(const std::string& path); void onGoToFileSelection(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); + void onGoToTxtReader(std::unique_ptr txt); public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp new file mode 100644 index 00000000..a8b8b808 --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -0,0 +1,622 @@ +#include "TxtReaderActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr unsigned long goHomeMs = 1000; +constexpr int topPadding = 10; +constexpr int horizontalPadding = 15; +constexpr int statusBarMargin = 25; +constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading +} // namespace + +void TxtReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void TxtReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + if (!txt) { + 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(); + + txt->setupCacheDir(); + + // Save current txt as last opened file + APP_STATE.openEpubPath = txt->getPath(); + APP_STATE.saveToFile(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask", + 6144, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void TxtReaderActivity::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 + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + pageOffsets.clear(); + currentPageLines.clear(); + txt.reset(); +} + +void TxtReaderActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Long press BACK (1s+) goes directly to home + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + onGoBack(); + return; + } + + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + if (!prevReleased && !nextReleased) { + return; + } + + if (prevReleased && currentPage > 0) { + currentPage--; + updateRequired = true; + } else if (nextReleased && currentPage < totalPages - 1) { + currentPage++; + updateRequired = true; + } +} + +void TxtReaderActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void TxtReaderActivity::initializeReader() { + if (initialized) { + return; + } + + // Calculate viewport dimensions + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); + orientedMarginTop += topPadding; + orientedMarginLeft += horizontalPadding; + orientedMarginRight += horizontalPadding; + orientedMarginBottom += statusBarMargin; + + viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId()); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, linesPerPage); + + // Try to load cached page index first + if (!loadPageIndexCache()) { + // Cache not found, build page index + buildPageIndex(); + // Save to cache for next time + savePageIndexCache(); + } + + // Load saved progress + loadProgress(); + + initialized = true; +} + +void TxtReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageOffsets.push_back(0); // First page starts at offset 0 + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + int lastProgressPercent = -1; + + Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); + + // Progress bar dimensions (matching EpubReaderActivity style) + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); + const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + const int barX = boxX + (boxWidth - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + + // Draw initial progress box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + + if (!loadPageAtOffset(offset, tempLines, nextOffset)) { + break; + } + + if (nextOffset <= offset) { + // No progress made, avoid infinite loop + break; + } + + offset = nextOffset; + if (offset < fileSize) { + pageOffsets.push_back(offset); + } + + // Update progress bar every 2% + int progressPercent = (offset * 100) / fileSize; + if (progressPercent != lastProgressPercent && progressPercent % 2 == 0) { + lastProgressPercent = progressPercent; + + // Fill progress bar + const int fillWidth = (barWidth - 2) * progressPercent / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + + // Yield to other tasks periodically + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); +} + +bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { + outLines.clear(); + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + return false; + } + + // Read a chunk from file + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) { + Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + // Parse lines from buffer + size_t pos = 0; + size_t bytesConsumed = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + if (!lineComplete && static_cast(outLines.size()) > 0) { + // Incomplete line and we already have some lines, stop here + break; + } + + // Extract line (without newline) + std::string line(reinterpret_cast(buffer + pos), lineEnd - pos); + + // Remove carriage return if present + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + // Word wrap if needed + while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { + int lineWidth = renderer.getTextWidth(SETTINGS.getReaderFontId(), line.c_str()); + + if (lineWidth <= viewportWidth) { + outLines.push_back(line); + break; + } + + // Find break point + size_t breakPos = line.length(); + while (breakPos > 0 && renderer.getTextWidth(SETTINGS.getReaderFontId(), line.substr(0, breakPos).c_str()) > viewportWidth) { + // Try to break at space + size_t spacePos = line.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + // Break at character boundary for UTF-8 + breakPos--; + // Make sure we don't break in the middle of a UTF-8 sequence + while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + breakPos = 1; + } + + outLines.push_back(line.substr(0, breakPos)); + + // Skip space at break point + if (breakPos < line.length() && line[breakPos] == ' ') { + breakPos++; + } + line = line.substr(breakPos); + } + + // If we still have remaining wrapped text but no room, don't consume this source line + if (!line.empty() && static_cast(outLines.size()) >= linesPerPage) { + break; + } + + // Move past the newline + bytesConsumed = lineEnd + 1; + pos = lineEnd + 1; + } + + // Handle case where we filled the page mid-line (word wrap) + if (bytesConsumed == 0 && !outLines.empty()) { + // We processed some wrapped content, estimate bytes consumed + // This is approximate - we need to track actual byte positions + bytesConsumed = pos; + } + + nextOffset = offset + (bytesConsumed > 0 ? bytesConsumed : chunkSize); + free(buffer); + + return !outLines.empty(); +} + +void TxtReaderActivity::renderScreen() { + if (!txt) { + return; + } + + // Initialize reader if not done + if (!initialized) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + // Bounds check + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page content + size_t offset = pageOffsets[currentPage]; + size_t nextOffset; + currentPageLines.clear(); + loadPageAtOffset(offset, currentPageLines, nextOffset); + + renderer.clearScreen(); + renderPage(); + + // Save progress + saveProgress(); +} + +void TxtReaderActivity::renderPage() { + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); + orientedMarginTop += topPadding; + orientedMarginLeft += horizontalPadding; + orientedMarginRight += horizontalPadding; + orientedMarginBottom += statusBarMargin; + + const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId()); + + int y = orientedMarginTop; + for (const auto& line : currentPageLines) { + if (!line.empty()) { + renderer.drawText(SETTINGS.getReaderFontId(), orientedMarginLeft, y, line.c_str()); + } + y += lineHeight; + } + + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } +} + +void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) const { + 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 showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + + const auto screenHeight = renderer.getScreenHeight(); + const auto textY = screenHeight - orientedMarginBottom - 4; + int progressTextWidth = 0; + + if (showProgress) { + const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0; + const std::string progressStr = + std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%"; + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str()); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progressStr.c_str()); + } + + if (showBattery) { + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + } + + if (showTitle) { + const int titleMarginLeft = 50 + 30 + orientedMarginLeft; + const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; + const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; + + std::string title = txt->getTitle(); + int 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()); + } +} + +void TxtReaderActivity::saveProgress() const { + FsFile f; + if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + f.close(); + } +} + +void TxtReaderActivity::loadProgress() { + FsFile f; + if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); + } + f.close(); + } +} + +bool TxtReaderActivity::loadPageIndexCache() { + // Cache file format: + // - 4 bytes: magic "TXTI" + // - 4 bytes: file size (to validate cache) + // - 4 bytes: viewport width + // - 4 bytes: lines per page + // - 4 bytes: total pages count + // - N * 4 bytes: page offsets (size_t stored as uint32_t) + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForRead("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); + return false; + } + + // Read and validate header + uint8_t header[20]; + if (f.read(header, 20) != 20) { + f.close(); + return false; + } + + // Check magic + if (header[0] != 'T' || header[1] != 'X' || header[2] != 'T' || header[3] != 'I') { + f.close(); + return false; + } + + // Check file size matches + uint32_t cachedFileSize = header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24); + if (cachedFileSize != txt->getFileSize()) { + Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + // Check viewport width matches + uint32_t cachedViewportWidth = header[8] | (header[9] << 8) | (header[10] << 16) | (header[11] << 24); + if (static_cast(cachedViewportWidth) != viewportWidth) { + Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + // Check lines per page matches + uint32_t cachedLinesPerPage = header[12] | (header[13] << 8) | (header[14] << 16) | (header[15] << 24); + if (static_cast(cachedLinesPerPage) != linesPerPage) { + Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + // Read total pages + uint32_t cachedTotalPages = header[16] | (header[17] << 8) | (header[18] << 16) | (header[19] << 24); + + // Read page offsets + pageOffsets.clear(); + pageOffsets.reserve(cachedTotalPages); + + for (uint32_t i = 0; i < cachedTotalPages; i++) { + uint8_t offsetData[4]; + if (f.read(offsetData, 4) != 4) { + f.close(); + pageOffsets.clear(); + return false; + } + uint32_t offset = offsetData[0] | (offsetData[1] << 8) | (offsetData[2] << 16) | (offsetData[3] << 24); + pageOffsets.push_back(offset); + } + + f.close(); + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); + return true; +} + +void TxtReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForWrite("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); + return; + } + + // Write header + uint8_t header[20]; + header[0] = 'T'; + header[1] = 'X'; + header[2] = 'T'; + header[3] = 'I'; + + // File size + uint32_t fileSize = txt->getFileSize(); + header[4] = fileSize & 0xFF; + header[5] = (fileSize >> 8) & 0xFF; + header[6] = (fileSize >> 16) & 0xFF; + header[7] = (fileSize >> 24) & 0xFF; + + // Viewport width + header[8] = viewportWidth & 0xFF; + header[9] = (viewportWidth >> 8) & 0xFF; + header[10] = (viewportWidth >> 16) & 0xFF; + header[11] = (viewportWidth >> 24) & 0xFF; + + // Lines per page + header[12] = linesPerPage & 0xFF; + header[13] = (linesPerPage >> 8) & 0xFF; + header[14] = (linesPerPage >> 16) & 0xFF; + header[15] = (linesPerPage >> 24) & 0xFF; + + // Total pages + uint32_t numPages = pageOffsets.size(); + header[16] = numPages & 0xFF; + header[17] = (numPages >> 8) & 0xFF; + header[18] = (numPages >> 16) & 0xFF; + header[19] = (numPages >> 24) & 0xFF; + + f.write(header, 20); + + // Write page offsets + for (size_t offset : pageOffsets) { + uint8_t offsetData[4]; + offsetData[0] = offset & 0xFF; + offsetData[1] = (offset >> 8) & 0xFF; + offsetData[2] = (offset >> 16) & 0xFF; + offsetData[3] = (offset >> 24) & 0xFF; + f.write(offsetData, 4); + } + + f.close(); + Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h new file mode 100644 index 00000000..0b9a64fd --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "activities/ActivityWithSubactivity.h" + +class TxtReaderActivity final : public ActivityWithSubactivity { + std::unique_ptr txt; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onGoHome; + + // Streaming text reader - stores file offsets for each page + std::vector pageOffsets; // File offset for start of each page + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + public: + explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt, + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("TxtReader", renderer, mappedInput), + txt(std::move(txt)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; From 2072741a59069f604a2eeeb6d913e5eca0e57716 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 4 Jan 2026 23:45:57 +0900 Subject: [PATCH 2/5] chore(clang-format-fix): fixing format --- lib/Txt/Txt.cpp | 8 +++++--- src/activities/reader/TxtReaderActivity.cpp | 14 +++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 3c8b37eb..52c75ed7 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -3,7 +3,8 @@ #include #include -Txt::Txt(std::string path, std::string cacheBasePath) : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { +Txt::Txt(std::string path, std::string cacheBasePath) + : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { // Generate cache path from file path hash const size_t hash = std::hash{}(filepath); cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash); @@ -112,8 +113,9 @@ bool Txt::generateCoverBmp() const { // Get file extension const size_t len = coverImagePath.length(); - const bool isJpg = (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || - (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); + const bool isJpg = + (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || + (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP"); if (isBmp) { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index a8b8b808..2208d5b4 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -141,7 +141,8 @@ void TxtReaderActivity::initializeReader() { // Calculate viewport dimensions int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; - renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); orientedMarginTop += topPadding; orientedMarginLeft += horizontalPadding; orientedMarginRight += horizontalPadding; @@ -154,7 +155,8 @@ void TxtReaderActivity::initializeReader() { linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; - Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, linesPerPage); + Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, + linesPerPage); // Try to load cached page index first if (!loadPageIndexCache()) { @@ -298,7 +300,8 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Find break point size_t breakPos = line.length(); - while (breakPos > 0 && renderer.getTextWidth(SETTINGS.getReaderFontId(), line.substr(0, breakPos).c_str()) > viewportWidth) { + while (breakPos > 0 && + renderer.getTextWidth(SETTINGS.getReaderFontId(), line.substr(0, breakPos).c_str()) > viewportWidth) { // Try to break at space size_t spacePos = line.rfind(' ', breakPos - 1); if (spacePos != std::string::npos && spacePos > 0) { @@ -388,7 +391,8 @@ void TxtReaderActivity::renderScreen() { void TxtReaderActivity::renderPage() { int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; - renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); orientedMarginTop += topPadding; orientedMarginLeft += horizontalPadding; orientedMarginRight += horizontalPadding; @@ -416,7 +420,7 @@ void TxtReaderActivity::renderPage() { } void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, - const int orientedMarginLeft) const { + const int orientedMarginLeft) const { 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; From f885efeeb93302557f8dfde301e0c2fe0929139b Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 9 Jan 2026 23:10:24 +0900 Subject: [PATCH 3/5] Address PR review feedback for TXT reader - Add font ID to cache header for invalidation when font changes - Use serialization module for consistent cache read/write - Add screenMargin setting and read padding from settings - Implement grayscale rendering pass for anti-aliased fonts - Add text alignment support (left, center, right, justified) - Bump cache version to invalidate old caches --- src/CrossPointSettings.cpp | 21 +- src/CrossPointSettings.h | 6 + src/activities/reader/TxtReaderActivity.cpp | 238 ++++++++++++-------- src/activities/reader/TxtReaderActivity.h | 6 + 4 files changed, 180 insertions(+), 91 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index d304c4e4..0991faf8 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 13; +constexpr uint8_t SETTINGS_COUNT = 14; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -40,6 +40,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, paragraphAlignment); serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); + serialization::writePod(outputFile, screenMargin); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -92,6 +93,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, screenMargin); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); @@ -167,6 +170,22 @@ int CrossPointSettings::getRefreshFrequency() const { } } +int CrossPointSettings::getScreenMargin() const { + switch (screenMargin) { + case MARGIN_0: + return 0; + case MARGIN_5: + default: + return 5; + case MARGIN_10: + return 10; + case MARGIN_15: + return 15; + case MARGIN_20: + return 20; + } +} + int CrossPointSettings::getReaderFontId() const { switch (fontFamily) { case BOOKERLY: diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bb38df68..579f607e 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -51,6 +51,9 @@ class CrossPointSettings { // E-ink refresh frequency (pages between full refreshes) enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + // Screen margin options + enum SCREEN_MARGIN { MARGIN_0 = 0, MARGIN_5 = 1, MARGIN_10 = 2, MARGIN_15 = 3, MARGIN_20 = 4 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Status bar settings @@ -74,6 +77,8 @@ class CrossPointSettings { uint8_t sleepTimeout = SLEEP_10_MIN; // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; + // Screen margin setting (default 5px) + uint8_t screenMargin = MARGIN_5; ~CrossPointSettings() = default; @@ -89,6 +94,7 @@ class CrossPointSettings { float getReaderLineCompression() const; unsigned long getSleepTimeoutMs() const; int getRefreshFrequency() const; + int getScreenMargin() const; }; // Helper macro to access settings diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 2208d5b4..ac642a89 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -12,10 +13,12 @@ namespace { constexpr unsigned long goHomeMs = 1000; -constexpr int topPadding = 10; -constexpr int horizontalPadding = 15; constexpr int statusBarMargin = 25; constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading + +// Cache file magic and version +constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" +constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes } // namespace void TxtReaderActivity::taskTrampoline(void* param) { @@ -139,18 +142,23 @@ void TxtReaderActivity::initializeReader() { return; } + // Store current settings for cache validation + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.getScreenMargin(); + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + // Calculate viewport dimensions int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); - orientedMarginTop += topPadding; - orientedMarginLeft += horizontalPadding; - orientedMarginRight += horizontalPadding; + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; orientedMarginBottom += statusBarMargin; viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; - const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId()); + const int lineHeight = renderer.getLineHeight(cachedFontId); linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; @@ -291,7 +299,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Word wrap if needed while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { - int lineWidth = renderer.getTextWidth(SETTINGS.getReaderFontId(), line.c_str()); + int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); if (lineWidth <= viewportWidth) { outLines.push_back(line); @@ -301,7 +309,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Find break point size_t breakPos = line.length(); while (breakPos > 0 && - renderer.getTextWidth(SETTINGS.getReaderFontId(), line.substr(0, breakPos).c_str()) > viewportWidth) { + renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { // Try to break at space size_t spacePos = line.rfind(' ', breakPos - 1); if (spacePos != std::string::npos && spacePos > 0) { @@ -393,21 +401,51 @@ void TxtReaderActivity::renderPage() { int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); - orientedMarginTop += topPadding; - orientedMarginLeft += horizontalPadding; - orientedMarginRight += horizontalPadding; + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; orientedMarginBottom += statusBarMargin; - const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId()); + const int lineHeight = renderer.getLineHeight(cachedFontId); + const int contentWidth = viewportWidth; - int y = orientedMarginTop; - for (const auto& line : currentPageLines) { - if (!line.empty()) { - renderer.drawText(SETTINGS.getReaderFontId(), orientedMarginLeft, y, line.c_str()); + // Render text lines with alignment + auto renderLines = [&]() { + int y = orientedMarginTop; + for (const auto& line : currentPageLines) { + if (!line.empty()) { + int x = orientedMarginLeft; + + // Apply text alignment + switch (cachedParagraphAlignment) { + case CrossPointSettings::LEFT_ALIGN: + default: + // x already set to left margin + break; + case CrossPointSettings::CENTER_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + (contentWidth - textWidth) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + contentWidth - textWidth; + break; + } + case CrossPointSettings::JUSTIFIED: + // For plain text, justified is treated as left-aligned + // (true justification would require word spacing adjustments) + break; + } + + renderer.drawText(cachedFontId, x, y, line.c_str()); + } + y += lineHeight; } - y += lineHeight; - } + }; + // First pass: BW rendering + renderLines(); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { @@ -417,6 +455,28 @@ void TxtReaderActivity::renderPage() { renderer.displayBuffer(); pagesUntilFullRefresh--; } + + // Save BW buffer for restoration after grayscale pass + renderer.storeBwBuffer(); + + // Grayscale rendering pass (for anti-aliased fonts) + { + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderLines(); + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderLines(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + } + + // Restore BW buffer + renderer.restoreBwBuffer(); } void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, @@ -492,13 +552,17 @@ void TxtReaderActivity::loadProgress() { } bool TxtReaderActivity::loadPageIndexCache() { - // Cache file format: - // - 4 bytes: magic "TXTI" - // - 4 bytes: file size (to validate cache) - // - 4 bytes: viewport width - // - 4 bytes: lines per page - // - 4 bytes: total pages count - // - N * 4 bytes: page offsets (size_t stored as uint32_t) + // Cache file format (using serialization module): + // - uint32_t: magic "TXTI" + // - uint8_t: cache version + // - uint32_t: file size (to validate cache) + // - int32_t: viewport width + // - int32_t: lines per page + // - int32_t: font ID (to invalidate cache on font change) + // - int32_t: screen margin (to invalidate cache on margin change) + // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - uint32_t: total pages count + // - N * uint32_t: page offsets std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; @@ -507,58 +571,81 @@ bool TxtReaderActivity::loadPageIndexCache() { return false; } - // Read and validate header - uint8_t header[20]; - if (f.read(header, 20) != 20) { + // Read and validate header using serialization module + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); f.close(); return false; } - // Check magic - if (header[0] != 'T' || header[1] != 'X' || header[2] != 'T' || header[3] != 'I') { + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); f.close(); return false; } - // Check file size matches - uint32_t cachedFileSize = header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24); - if (cachedFileSize != txt->getFileSize()) { + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); f.close(); return false; } - // Check viewport width matches - uint32_t cachedViewportWidth = header[8] | (header[9] << 8) | (header[10] << 16) | (header[11] << 24); - if (static_cast(cachedViewportWidth) != viewportWidth) { + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); f.close(); return false; } - // Check lines per page matches - uint32_t cachedLinesPerPage = header[12] | (header[13] << 8) | (header[14] << 16) | (header[15] << 24); - if (static_cast(cachedLinesPerPage) != linesPerPage) { + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); f.close(); return false; } - // Read total pages - uint32_t cachedTotalPages = header[16] | (header[17] << 8) | (header[18] << 16) | (header[19] << 24); + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); + f.close(); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); // Read page offsets pageOffsets.clear(); - pageOffsets.reserve(cachedTotalPages); + pageOffsets.reserve(numPages); - for (uint32_t i = 0; i < cachedTotalPages; i++) { - uint8_t offsetData[4]; - if (f.read(offsetData, 4) != 4) { - f.close(); - pageOffsets.clear(); - return false; - } - uint32_t offset = offsetData[0] | (offsetData[1] << 8) | (offsetData[2] << 16) | (offsetData[3] << 24); + for (uint32_t i = 0; i < numPages; i++) { + uint32_t offset; + serialization::readPod(f, offset); pageOffsets.push_back(offset); } @@ -576,49 +663,20 @@ void TxtReaderActivity::savePageIndexCache() const { return; } - // Write header - uint8_t header[20]; - header[0] = 'T'; - header[1] = 'X'; - header[2] = 'T'; - header[3] = 'I'; - - // File size - uint32_t fileSize = txt->getFileSize(); - header[4] = fileSize & 0xFF; - header[5] = (fileSize >> 8) & 0xFF; - header[6] = (fileSize >> 16) & 0xFF; - header[7] = (fileSize >> 24) & 0xFF; - - // Viewport width - header[8] = viewportWidth & 0xFF; - header[9] = (viewportWidth >> 8) & 0xFF; - header[10] = (viewportWidth >> 16) & 0xFF; - header[11] = (viewportWidth >> 24) & 0xFF; - - // Lines per page - header[12] = linesPerPage & 0xFF; - header[13] = (linesPerPage >> 8) & 0xFF; - header[14] = (linesPerPage >> 16) & 0xFF; - header[15] = (linesPerPage >> 24) & 0xFF; - - // Total pages - uint32_t numPages = pageOffsets.size(); - header[16] = numPages & 0xFF; - header[17] = (numPages >> 8) & 0xFF; - header[18] = (numPages >> 16) & 0xFF; - header[19] = (numPages >> 24) & 0xFF; - - f.write(header, 20); + // Write header using serialization module + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); // Write page offsets for (size_t offset : pageOffsets) { - uint8_t offsetData[4]; - offsetData[0] = offset & 0xFF; - offsetData[1] = (offset >> 8) & 0xFF; - offsetData[2] = (offset >> 16) & 0xFF; - offsetData[3] = (offset >> 24) & 0xFF; - f.write(offsetData, 4); + serialization::writePod(f, static_cast(offset)); } f.close(); diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 0b9a64fd..41ccbfbb 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -7,6 +7,7 @@ #include +#include "CrossPointSettings.h" #include "activities/ActivityWithSubactivity.h" class TxtReaderActivity final : public ActivityWithSubactivity { @@ -27,6 +28,11 @@ class TxtReaderActivity final : public ActivityWithSubactivity { int viewportWidth = 0; bool initialized = false; + // Cached settings for cache validation (different fonts/margins require re-indexing) + int cachedFontId = 0; + int cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void renderScreen(); From 801429f68cc7c1b0deda3ae5cc97ddacecc97896 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sat, 10 Jan 2026 00:04:42 +0900 Subject: [PATCH 4/5] chore(clang-format-fix): fixing format --- src/activities/reader/TxtReaderActivity.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index ad38bc75..95524e0c 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -308,8 +308,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Find break point size_t breakPos = line.length(); - while (breakPos > 0 && - renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { + while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { // Try to break at space size_t spacePos = line.rfind(' ', breakPos - 1); if (spacePos != std::string::npos && spacePos > 0) { From 9ab69fb1ff261fe3b9498bd51851dbb78d04d5d8 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Wed, 14 Jan 2026 02:40:13 +0900 Subject: [PATCH 5/5] fix(TxtReader): Address PR review feedback - Fix progress update logic: Use lastProgress / 10 != progress / 10 instead of progressPercent % 2 == 0 to ensure progress updates even when percentage jumps odd numbers (matching EpubReaderActivity) - Fix BW buffer store/restore: Only perform buffer operations when textAntiAliasing is enabled (grayscale passes are being rendered) - Add power button page turn support: Respect the new shortPwrBtn PAGE_TURN setting for next page navigation - Fix word wrap byte tracking: Properly track consumed bytes during word wrapping to prevent repeated sections and ensure file end is reachable. The previous implementation had incorrect byte offset calculations when lines were split across pages. --- src/activities/reader/TxtReaderActivity.cpp | 75 +++++++++++++-------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 95524e0c..db725320 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -110,6 +110,8 @@ void TxtReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { @@ -227,9 +229,9 @@ void TxtReaderActivity::buildPageIndex() { pageOffsets.push_back(offset); } - // Update progress bar every 2% + // Update progress bar every 10% (matching EpubReaderActivity logic) int progressPercent = (offset * 100) / fileSize; - if (progressPercent != lastProgressPercent && progressPercent % 2 == 0) { + if (lastProgressPercent / 10 != progressPercent / 10) { lastProgressPercent = progressPercent; // Fill progress bar @@ -272,7 +274,6 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Parse lines from buffer size_t pos = 0; - size_t bytesConsumed = 0; while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { // Find end of line @@ -289,13 +290,18 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector break; } - // Extract line (without newline) - std::string line(reinterpret_cast(buffer + pos), lineEnd - pos); + // Calculate the actual length of line content in the buffer (excluding newline) + size_t lineContentLen = lineEnd - pos; - // Remove carriage return if present - if (!line.empty() && line.back() == '\r') { - line.pop_back(); - } + // Check for carriage return + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + // Extract line content for display (without CR/LF) + std::string line(reinterpret_cast(buffer + pos), displayLen); + + // Track position within this source line (in bytes from pos) + size_t lineBytePos = 0; // Word wrap if needed while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { @@ -303,6 +309,8 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector if (lineWidth <= viewportWidth) { outLines.push_back(line); + lineBytePos = displayLen; // Consumed entire display content + line.clear(); break; } @@ -330,30 +338,39 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector outLines.push_back(line.substr(0, breakPos)); // Skip space at break point + size_t skipChars = breakPos; if (breakPos < line.length() && line[breakPos] == ' ') { - breakPos++; + skipChars++; } - line = line.substr(breakPos); + lineBytePos += skipChars; + line = line.substr(skipChars); } - // If we still have remaining wrapped text but no room, don't consume this source line - if (!line.empty() && static_cast(outLines.size()) >= linesPerPage) { + // Determine how much of the source buffer we consumed + if (line.empty()) { + // Fully consumed this source line, move past the newline + pos = lineEnd + 1; + } else { + // Partially consumed - page is full mid-line + // Move pos to where we stopped in the line (NOT past the line) + pos = pos + lineBytePos; break; } - - // Move past the newline - bytesConsumed = lineEnd + 1; - pos = lineEnd + 1; } - // Handle case where we filled the page mid-line (word wrap) - if (bytesConsumed == 0 && !outLines.empty()) { - // We processed some wrapped content, estimate bytes consumed - // This is approximate - we need to track actual byte positions - bytesConsumed = pos; + // Ensure we make progress even if calculations go wrong + if (pos == 0 && !outLines.empty()) { + // Fallback: at minimum, consume something to avoid infinite loop + pos = 1; + } + + nextOffset = offset + pos; + + // Make sure we don't go past the file + if (nextOffset > fileSize) { + nextOffset = fileSize; } - nextOffset = offset + (bytesConsumed > 0 ? bytesConsumed : chunkSize); free(buffer); return !outLines.empty(); @@ -455,11 +472,11 @@ void TxtReaderActivity::renderPage() { pagesUntilFullRefresh--; } - // Save BW buffer for restoration after grayscale pass - renderer.storeBwBuffer(); - // Grayscale rendering pass (for anti-aliased fonts) if (SETTINGS.textAntiAliasing) { + // Save BW buffer for restoration after grayscale pass + renderer.storeBwBuffer(); + renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderLines(); @@ -472,10 +489,10 @@ void TxtReaderActivity::renderPage() { renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); - } - // Restore BW buffer - renderer.restoreBwBuffer(); + // Restore BW buffer + renderer.restoreBwBuffer(); + } } void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,