This repository has been archived on 2026-02-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
crosspoint-reader/src/activities/boot_sleep/SleepActivity.cpp

609 lines
23 KiB
C++
Raw Normal View History

#include "SleepActivity.h"
2025-12-06 04:20:03 +11:00
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
#include <Txt.h>
#include <Xtc.h>
2025-12-06 04:20:03 +11:00
#include <cmath>
#include "BookManager.h"
2025-12-15 23:17:23 +11:00
#include "CrossPointSettings.h"
#include "CrossPointState.h"
Aleo, Noto Sans, Open Dyslexic fonts (#163) ## Summary * Swap out Bookerly font due to licensing issues, replace default font with Aleo * I did a bunch of searching around for a nice replacement font, and this trumped several other like Literata, Merriwether, Vollkorn, etc * Add Noto Sans, and Open Dyslexic as font options * They can be selected in the settings screen * Add font size options (Small, Medium, Large, Extra Large) * Adjustable in settings * Swap out uses of reader font in headings and replaced with slightly larger Ubuntu font * Replaced PixelArial14 font as it was difficult to track down, replace with Space Grotesk * Remove auto formatting on generated font files * Massively speeds up formatting step now that there is a lot more CPP font source * Include fonts with their licenses in the repo ## Additional Context Line compression setting will follow | Font | Small | Medium | Large | X Large | | --- | --- | --- | --- | --- | | Aleo | ![IMG_5704](https://github.com/user-attachments/assets/7acb054f-ddef-4080-b3c8-590cfaf13115) | ![IMG_5705](https://github.com/user-attachments/assets/d4819036-5c89-486e-92c3-86094fa4d89a) | ![IMG_5706](https://github.com/user-attachments/assets/35caf622-d126-4396-9c3e-f927eba1e1f4) | ![IMG_5707](https://github.com/user-attachments/assets/af32370a-6244-400f-bea9-5c27db040b5b) | | Noto Sans | ![IMG_5708](https://github.com/user-attachments/assets/1f9264a5-c069-4e22-9099-a082bfcaabc5) | ![IMG_5709](https://github.com/user-attachments/assets/ef6b07fe-8d87-403a-b152-05f50b69b78e) | ![IMG_5710](https://github.com/user-attachments/assets/112a5d20-262c-4dc0-b67d-980b237e4607) | ![IMG_5711](https://github.com/user-attachments/assets/d25e0e1d-2ace-450d-96dd-618e4efd4805) | | Open Dyslexic | ![IMG_5712](https://github.com/user-attachments/assets/ead64690-f261-4fae-a4a2-0becd1162e2d) | ![IMG_5713](https://github.com/user-attachments/assets/59d60f7d-5142-4591-96b0-c04e0a4c6436) | ![IMG_5714](https://github.com/user-attachments/assets/bb6652cd-1790-46a3-93ea-2b8f70d0d36d) | ![IMG_5715](https://github.com/user-attachments/assets/496e7eb4-c81a-4232-83e9-9ba9148fdea4) |
2025-12-30 18:21:47 +10:00
#include "fontIds.h"
#include "images/CrossLarge.h"
#include "util/StringUtils.h"
namespace {
// Edge luminance cache file format (per-BMP):
// - 4 bytes: uint32_t file size (for cache invalidation)
// - 4 bytes: EdgeLuminance (top, bottom, left, right)
constexpr size_t EDGE_CACHE_SIZE = 8;
// Book-level edge cache file format (stored in book's cache directory):
// - 4 bytes: uint32_t cover BMP file size (for cache invalidation)
// - 4 bytes: EdgeLuminance (top, bottom, left, right)
// - 1 byte: cover mode (0=FIT, 1=CROP) - for EPUB mode invalidation
constexpr size_t BOOK_EDGE_CACHE_SIZE = 9;
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen();
}
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) {
return renderCustomSleepScreen();
}
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) {
return renderCoverSleepScreen();
}
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
Aleo, Noto Sans, Open Dyslexic fonts (#163) ## Summary * Swap out Bookerly font due to licensing issues, replace default font with Aleo * I did a bunch of searching around for a nice replacement font, and this trumped several other like Literata, Merriwether, Vollkorn, etc * Add Noto Sans, and Open Dyslexic as font options * They can be selected in the settings screen * Add font size options (Small, Medium, Large, Extra Large) * Adjustable in settings * Swap out uses of reader font in headings and replaced with slightly larger Ubuntu font * Replaced PixelArial14 font as it was difficult to track down, replace with Space Grotesk * Remove auto formatting on generated font files * Massively speeds up formatting step now that there is a lot more CPP font source * Include fonts with their licenses in the repo ## Additional Context Line compression setting will follow | Font | Small | Medium | Large | X Large | | --- | --- | --- | --- | --- | | Aleo | ![IMG_5704](https://github.com/user-attachments/assets/7acb054f-ddef-4080-b3c8-590cfaf13115) | ![IMG_5705](https://github.com/user-attachments/assets/d4819036-5c89-486e-92c3-86094fa4d89a) | ![IMG_5706](https://github.com/user-attachments/assets/35caf622-d126-4396-9c3e-f927eba1e1f4) | ![IMG_5707](https://github.com/user-attachments/assets/af32370a-6244-400f-bea9-5c27db040b5b) | | Noto Sans | ![IMG_5708](https://github.com/user-attachments/assets/1f9264a5-c069-4e22-9099-a082bfcaabc5) | ![IMG_5709](https://github.com/user-attachments/assets/ef6b07fe-8d87-403a-b152-05f50b69b78e) | ![IMG_5710](https://github.com/user-attachments/assets/112a5d20-262c-4dc0-b67d-980b237e4607) | ![IMG_5711](https://github.com/user-attachments/assets/d25e0e1d-2ace-450d-96dd-618e4efd4805) | | Open Dyslexic | ![IMG_5712](https://github.com/user-attachments/assets/ead64690-f261-4fae-a4a2-0becd1162e2d) | ![IMG_5713](https://github.com/user-attachments/assets/59d60f7d-5142-4591-96b0-c04e0a4c6436) | ![IMG_5714](https://github.com/user-attachments/assets/bb6652cd-1790-46a3-93ea-2b8f70d0d36d) | ![IMG_5715](https://github.com/user-attachments/assets/496e7eb4-c81a-4232-83e9-9ba9148fdea4) |
2025-12-30 18:21:47 +10:00
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SdMan.open("/sleep");
if (dir && dir.isDirectory()) {
std::vector<std::string> files;
char name[500];
// collect all valid BMP files
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (file.isDirectory()) {
file.close();
continue;
}
file.getName(name, sizeof(name));
auto filename = std::string(name);
if (filename[0] == '.') {
file.close();
continue;
}
if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name);
file.close();
continue;
}
Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name);
file.close();
continue;
}
files.emplace_back(filename);
file.close();
}
const auto numFiles = files.size();
if (numFiles > 0) {
// Generate a random number between 1 and numFiles
auto randomFileIndex = random(numFiles);
// If we picked the same image as last time, reroll
while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) {
randomFileIndex = random(numFiles);
}
APP_STATE.lastSleepImage = randomFileIndex;
APP_STATE.saveToFile();
const auto bmpPath = "/sleep/" + files[randomFileIndex];
FsFile file;
if (SdMan.openFileForRead("SLP", bmpPath, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap, bmpPath);
dir.close();
return;
}
}
}
}
if (dir) dir.close();
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
FsFile file;
const std::string rootSleepPath = "/sleep.bmp";
if (SdMan.openFileForRead("SLP", rootSleepPath, file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
renderBitmapSleepScreen(bitmap, rootSleepPath);
return;
}
}
renderDefaultSleepScreen();
}
void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
2025-12-08 22:52:19 +11:00
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Make sleep screen dark unless light is selected in settings
if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) {
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
int x, y;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
float cropX = 0, cropY = 0;
2026-01-22 13:13:12 -05:00
int drawWidth = pageWidth;
int drawHeight = pageHeight;
int fillWidth = pageWidth; // Actual area the image will occupy
int fillHeight = pageHeight;
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
pageWidth, pageHeight);
2026-01-22 13:13:12 -05:00
const float bitmapRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), bitmapRatio, screenRatio);
const auto coverMode = SETTINGS.sleepScreenCoverMode;
if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::ACTUAL) {
// ACTUAL mode: Show image at actual size, centered (no scaling)
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
2026-01-22 13:13:12 -05:00
// Don't constrain to screen dimensions - drawBitmap will clip
drawWidth = 0;
drawHeight = 0;
fillWidth = bitmap.getWidth();
fillHeight = bitmap.getHeight();
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d\n", millis(), x, y);
} else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
// CROP mode: Scale to fill screen completely (may crop edges)
// Calculate crop values to fill the screen while maintaining aspect ratio
if (bitmapRatio > screenRatio) {
// Image is wider than screen ratio - crop horizontally
cropX = 1.0f - (screenRatio / bitmapRatio);
Serial.printf("[%lu] [SLP] CROP mode: cropping x by %f\n", millis(), cropX);
} else if (bitmapRatio < screenRatio) {
// Image is taller than screen ratio - crop vertically
cropY = 1.0f - (bitmapRatio / screenRatio);
Serial.printf("[%lu] [SLP] CROP mode: cropping y by %f\n", millis(), cropY);
}
// After cropping, the image should fill the screen exactly
x = 0;
y = 0;
fillWidth = pageWidth;
fillHeight = pageHeight;
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] CROP mode: drawing at 0, 0 with crop %f, %f\n", millis(), cropX, cropY);
} else {
// FIT mode (default): Scale to fit entire image within screen (may have letterboxing)
// Calculate the scaled dimensions
float scale;
if (bitmapRatio > screenRatio) {
// Image is wider than screen ratio - fit to width
scale = static_cast<float>(pageWidth) / static_cast<float>(bitmap.getWidth());
} else {
// Image is taller than screen ratio - fit to height
scale = static_cast<float>(pageHeight) / static_cast<float>(bitmap.getHeight());
}
// Use ceil to ensure fill area covers all drawn pixels
fillWidth = static_cast<int>(std::ceil(bitmap.getWidth() * scale));
fillHeight = static_cast<int>(std::ceil(bitmap.getHeight() * scale));
2026-01-22 13:13:12 -05:00
// Center the scaled image
x = (pageWidth - fillWidth) / 2;
y = (pageHeight - fillHeight) / 2;
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale,
fillWidth, fillHeight, x, y);
}
// Get edge luminance values (from cache or calculate)
const EdgeLuminance edges = getEdgeLuminance(bitmap, bmpPath);
const uint8_t topGray = quantizeGray(edges.top);
const uint8_t bottomGray = quantizeGray(edges.bottom);
const uint8_t leftGray = quantizeGray(edges.left);
const uint8_t rightGray = quantizeGray(edges.right);
Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n",
millis(), edges.top, edges.bottom, edges.left, edges.right,
topGray, bottomGray, leftGray, rightGray);
feat: Add support to B&W filters to image covers (#476) * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Implementation of a new feature in Display options as Image Filter * **What changes are included?** Black & White and Inverted Black & White options are added. Here are some examples: | None | Contrast | Inverted | | --- | --- | --- | | <img alt="image" src="https://github.com/user-attachments/assets/fe02dd9b-f647-41bd-8495-c262f73177c4" /> | <img alt="image" src="https://github.com/user-attachments/assets/2d17747d-3ff6-48a9-b9b9-eb17cccf19cf" /> | <img alt="image" src="https://github.com/user-attachments/assets/792dea50-f003-4634-83fe-77849ca49095" /> | | <img alt="image" src="https://github.com/user-attachments/assets/28395b63-14f8-41e2-886b-8ddf3faeafc4" /> | <img alt="image" src="https://github.com/user-attachments/assets/71a569c8-fc54-4647-ad4c-ec96e220cddb" /> | <img alt="image" src="https://github.com/user-attachments/assets/9139e32c-9175-433e-8372-45fa042d3dc9" /> | * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). I have also tried adding Color inversion, but could not see much difference with that. It might be because my implementation was wrong. --- While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _** PARTIALLY **_ --------- Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-27 13:21:59 +00:00
// Check if greyscale pass should be used (PR #476: skip if filter is applied)
const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Clear screen to white first (default background)
renderer.clearScreen(0xFF);
// Fill letterbox regions with edge colors (BW pass)
// Top letterbox
if (y > 0) {
renderer.fillRectGray(0, 0, pageWidth, y, topGray);
}
// Bottom letterbox
if (y + fillHeight < pageHeight) {
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
}
// Left letterbox
if (x > 0) {
renderer.fillRectGray(0, y, x, fillHeight, leftGray);
}
// Right letterbox
if (x + fillWidth < pageWidth) {
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
}
Serial.printf("[%lu] [SLP] drawing bitmap at %d, %d\n", millis(), x, y);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
feat: Add support to B&W filters to image covers (#476) * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Implementation of a new feature in Display options as Image Filter * **What changes are included?** Black & White and Inverted Black & White options are added. Here are some examples: | None | Contrast | Inverted | | --- | --- | --- | | <img alt="image" src="https://github.com/user-attachments/assets/fe02dd9b-f647-41bd-8495-c262f73177c4" /> | <img alt="image" src="https://github.com/user-attachments/assets/2d17747d-3ff6-48a9-b9b9-eb17cccf19cf" /> | <img alt="image" src="https://github.com/user-attachments/assets/792dea50-f003-4634-83fe-77849ca49095" /> | | <img alt="image" src="https://github.com/user-attachments/assets/28395b63-14f8-41e2-886b-8ddf3faeafc4" /> | <img alt="image" src="https://github.com/user-attachments/assets/71a569c8-fc54-4647-ad4c-ec96e220cddb" /> | <img alt="image" src="https://github.com/user-attachments/assets/9139e32c-9175-433e-8372-45fa042d3dc9" /> | * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). I have also tried adding Color inversion, but could not see much difference with that. It might be because my implementation was wrong. --- While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _** PARTIALLY **_ --------- Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-27 13:21:59 +00:00
// PR #476: Apply inverted B&W filter if selected
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
feat: Add support to B&W filters to image covers (#476) * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Implementation of a new feature in Display options as Image Filter * **What changes are included?** Black & White and Inverted Black & White options are added. Here are some examples: | None | Contrast | Inverted | | --- | --- | --- | | <img alt="image" src="https://github.com/user-attachments/assets/fe02dd9b-f647-41bd-8495-c262f73177c4" /> | <img alt="image" src="https://github.com/user-attachments/assets/2d17747d-3ff6-48a9-b9b9-eb17cccf19cf" /> | <img alt="image" src="https://github.com/user-attachments/assets/792dea50-f003-4634-83fe-77849ca49095" /> | | <img alt="image" src="https://github.com/user-attachments/assets/28395b63-14f8-41e2-886b-8ddf3faeafc4" /> | <img alt="image" src="https://github.com/user-attachments/assets/71a569c8-fc54-4647-ad4c-ec96e220cddb" /> | <img alt="image" src="https://github.com/user-attachments/assets/9139e32c-9175-433e-8372-45fa042d3dc9" /> | * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). I have also tried adding Color inversion, but could not see much difference with that. It might be because my implementation was wrong. --- While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _** PARTIALLY **_ --------- Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-27 13:21:59 +00:00
if (hasGreyscale) {
// Grayscale LSB pass
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
// Fill letterbox regions for LSB pass
if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray);
if (y + fillHeight < pageHeight)
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray);
if (x + fillWidth < pageWidth)
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
// Grayscale MSB pass
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
// Fill letterbox regions for MSB pass
if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray);
if (y + fillHeight < pageHeight)
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray);
if (x + fillWidth < pageWidth)
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
}
void SleepActivity::renderCoverSleepScreen() const {
if (APP_STATE.openEpubPath.empty()) {
return renderDefaultSleepScreen();
}
const bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Try to use cached edge data to skip book metadata loading
if (tryRenderCachedCoverSleep(APP_STATE.openEpubPath, cropped)) {
return;
}
// Cache miss - need to load book metadata and generate cover
Serial.printf("[%lu] [SLP] Cache miss, loading book metadata\n", millis());
std::string coverBmpPath;
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
// Check if the current book is XTC, TXT, or EPUB
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
// 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();
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
// 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 if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
// 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(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen();
}
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
} else {
return renderDefaultSleepScreen();
}
FsFile file;
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Render the bitmap - this will calculate and cache edge luminance per-BMP
renderBitmapSleepScreen(bitmap, coverBmpPath);
// Also save to book-level edge cache for faster subsequent sleeps
const std::string edgeCachePath = getBookEdgeCachePath(APP_STATE.openEpubPath);
if (!edgeCachePath.empty()) {
// Get the edge luminance that was just calculated (it's now cached per-BMP)
const EdgeLuminance edges = getEdgeLuminance(bitmap, coverBmpPath);
const uint32_t bmpFileSize = bitmap.getFileSize();
const uint8_t coverMode = cropped ? 1 : 0;
FsFile cacheFile;
if (SdMan.openFileForWrite("SLP", edgeCachePath, cacheFile)) {
uint8_t cacheData[BOOK_EDGE_CACHE_SIZE];
cacheData[0] = bmpFileSize & 0xFF;
cacheData[1] = (bmpFileSize >> 8) & 0xFF;
cacheData[2] = (bmpFileSize >> 16) & 0xFF;
cacheData[3] = (bmpFileSize >> 24) & 0xFF;
cacheData[4] = edges.top;
cacheData[5] = edges.bottom;
cacheData[6] = edges.left;
cacheData[7] = edges.right;
cacheData[8] = coverMode;
cacheFile.write(cacheData, BOOK_EDGE_CACHE_SIZE);
cacheFile.close();
Serial.printf("[%lu] [SLP] Saved book-level edge cache to %s\n", millis(), edgeCachePath.c_str());
}
}
return;
}
}
renderDefaultSleepScreen();
}
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
// Convert "/dir/file.bmp" to "/dir/.file.bmp.perim"
const size_t lastSlash = bmpPath.find_last_of('/');
if (lastSlash == std::string::npos) {
// No directory, just prepend dot
return "." + bmpPath + ".perim";
}
const std::string dir = bmpPath.substr(0, lastSlash + 1);
const std::string filename = bmpPath.substr(lastSlash + 1);
return dir + "." + filename + ".perim";
}
uint8_t SleepActivity::quantizeGray(uint8_t lum) {
// Quantize luminance (0-255) to 4-level grayscale (0-3)
// Thresholds tuned for X4 display gray levels
if (lum < 43) return 0; // black
if (lum < 128) return 1; // dark gray
if (lum < 213) return 2; // light gray
return 3; // white
}
EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const {
const std::string cachePath = getEdgeCachePath(bmpPath);
EdgeLuminance result = {128, 128, 128, 128}; // Default to neutral gray
// Try to read from cache
FsFile cacheFile;
if (SdMan.openFileForRead("SLP", cachePath, cacheFile)) {
uint8_t cacheData[EDGE_CACHE_SIZE];
if (cacheFile.read(cacheData, EDGE_CACHE_SIZE) == EDGE_CACHE_SIZE) {
// Extract cached file size
const uint32_t cachedSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
// Get current BMP file size from already-opened bitmap
const uint32_t currentSize = bitmap.getFileSize();
// Validate cache
if (cachedSize == currentSize && currentSize > 0) {
result.top = cacheData[4];
result.bottom = cacheData[5];
result.left = cacheData[6];
result.right = cacheData[7];
Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(),
result.top, result.bottom, result.left, result.right);
cacheFile.close();
return result;
}
Serial.printf("[%lu] [SLP] Edge cache invalid (size mismatch: %lu vs %lu)\n", millis(),
static_cast<unsigned long>(cachedSize), static_cast<unsigned long>(currentSize));
}
cacheFile.close();
}
// Cache miss - calculate edge luminance
Serial.printf("[%lu] [SLP] Calculating edge luminance for %s\n", millis(), bmpPath.c_str());
result = bitmap.detectEdgeLuminance(2); // Sample 2 pixels deep for stability
Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(),
result.top, result.bottom, result.left, result.right);
// Get BMP file size from already-opened bitmap for cache
const uint32_t fileSize = bitmap.getFileSize();
// Save to cache
if (fileSize > 0 && SdMan.openFileForWrite("SLP", cachePath, cacheFile)) {
uint8_t cacheData[EDGE_CACHE_SIZE];
cacheData[0] = fileSize & 0xFF;
cacheData[1] = (fileSize >> 8) & 0xFF;
cacheData[2] = (fileSize >> 16) & 0xFF;
cacheData[3] = (fileSize >> 24) & 0xFF;
cacheData[4] = result.top;
cacheData[5] = result.bottom;
cacheData[6] = result.left;
cacheData[7] = result.right;
cacheFile.write(cacheData, EDGE_CACHE_SIZE);
cacheFile.close();
Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), cachePath.c_str());
}
return result;
}
std::string SleepActivity::getBookEdgeCachePath(const std::string& bookPath) {
const std::string cacheDir = BookManager::getCacheDir(bookPath);
if (cacheDir.empty()) {
return "";
}
return cacheDir + "/edge.bin";
}
std::string SleepActivity::getCoverBmpPath(const std::string& cacheDir, const std::string& bookPath, bool cropped) {
if (cacheDir.empty()) {
return "";
}
// EPUB uses different paths for fit vs crop modes
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp");
}
// XTC and TXT use a single cover.bmp
return cacheDir + "/cover.bmp";
}
bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool cropped) const {
// Try to render cover sleep screen using cached edge data without loading book metadata
const std::string edgeCachePath = getBookEdgeCachePath(bookPath);
if (edgeCachePath.empty()) {
Serial.println("[SLP] Cannot get edge cache path");
return false;
}
// Check if edge cache exists
FsFile cacheFile;
if (!SdMan.openFileForRead("SLP", edgeCachePath, cacheFile)) {
Serial.println("[SLP] No edge cache file found");
return false;
}
// Read cache data
uint8_t cacheData[BOOK_EDGE_CACHE_SIZE];
if (cacheFile.read(cacheData, BOOK_EDGE_CACHE_SIZE) != BOOK_EDGE_CACHE_SIZE) {
Serial.println("[SLP] Edge cache file too small");
cacheFile.close();
return false;
}
cacheFile.close();
// Extract cached values
const uint32_t cachedBmpSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
EdgeLuminance cachedEdges;
cachedEdges.top = cacheData[4];
cachedEdges.bottom = cacheData[5];
cachedEdges.left = cacheData[6];
cachedEdges.right = cacheData[7];
const uint8_t cachedCoverMode = cacheData[8];
// Check if cover mode matches (for EPUB)
const uint8_t currentCoverMode = cropped ? 1 : 0;
if (StringUtils::checkFileExtension(bookPath, ".epub") && cachedCoverMode != currentCoverMode) {
Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n",
cachedCoverMode, currentCoverMode);
return false;
}
// Construct cover BMP path
const std::string cacheDir = BookManager::getCacheDir(bookPath);
const std::string coverBmpPath = getCoverBmpPath(cacheDir, bookPath, cropped);
if (coverBmpPath.empty()) {
Serial.println("[SLP] Cannot construct cover BMP path");
return false;
}
// Try to open the cover BMP
FsFile bmpFile;
if (!SdMan.openFileForRead("SLP", coverBmpPath, bmpFile)) {
Serial.printf("[SLP] Cover BMP not found: %s\n", coverBmpPath.c_str());
return false;
}
// Check if BMP file size matches cache
const uint32_t currentBmpSize = bmpFile.size();
if (currentBmpSize != cachedBmpSize || currentBmpSize == 0) {
Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n",
static_cast<unsigned long>(cachedBmpSize), static_cast<unsigned long>(currentBmpSize));
bmpFile.close();
return false;
}
// Parse bitmap headers
Bitmap bitmap(bmpFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.println("[SLP] Failed to parse cached cover BMP");
bmpFile.close();
return false;
}
Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(),
coverBmpPath.c_str(), cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right);
// Render the bitmap with cached edge values
// We call renderBitmapSleepScreen which will use getEdgeLuminance internally,
// but since the per-BMP cache should also exist (same values), it will be a cache hit
renderBitmapSleepScreen(bitmap, coverBmpPath);
return true;
}