375 lines
12 KiB
C++
Raw Normal View History

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 <FsHelpers.h>
#include <JpegToBmpConverter.h>
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<std::string>{}(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;
}
2026-01-24 02:01:53 -05:00
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
bool Txt::generateThumbBmp() const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) {
return true;
}
std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for thumbnail\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"));
if (isJpg) {
// Convert JPG to 1-bit BMP thumbnail
Serial.printf("[%lu] [TXT] Generating thumb BMP from JPG cover image\n", millis());
FsFile coverJpg, thumbBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getThumbBmpPath(), thumbBmp)) {
coverJpg.close();
return false;
}
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success =
JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str());
} else {
Serial.printf("[%lu] [TXT] Generated thumb BMP from JPG cover image\n", millis());
}
return success;
}
// For BMP files, just copy cover.bmp to thumb.bmp (no scaling for BMP)
if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) {
FsFile src, dst;
if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("TXT", getThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [TXT] Copied cover to thumb\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
}
return false;
}
std::string Txt::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; }
bool Txt::generateMicroThumbBmp() const {
// Already generated, return true
if (SdMan.exists(getMicroThumbBmpPath().c_str())) {
return true;
}
std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for micro thumbnail\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"));
if (isJpg) {
// Convert JPG to 1-bit BMP micro thumbnail
Serial.printf("[%lu] [TXT] Generating micro thumb BMP from JPG cover image\n", millis());
FsFile coverJpg, microThumbBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), microThumbBmp)) {
coverJpg.close();
return false;
}
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, microThumbBmp,
MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT);
coverJpg.close();
microThumbBmp.close();
if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate micro thumb BMP from JPG cover image\n", millis());
SdMan.remove(getMicroThumbBmpPath().c_str());
} else {
Serial.printf("[%lu] [TXT] Generated micro thumb BMP from JPG cover image\n", millis());
}
return success;
}
// For BMP files, just copy cover.bmp to micro_thumb.bmp (no scaling for BMP)
if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) {
FsFile src, dst;
if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [TXT] Copied cover to micro thumb\n", millis());
return SdMan.exists(getMicroThumbBmpPath().c_str());
}
return false;
}
bool Txt::generateAllCovers(const std::function<void(int)>& progressCallback) const {
// Check if all covers already exist
const bool hasCover = SdMan.exists(getCoverBmpPath().c_str());
const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str());
const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str());
if (hasCover && hasThumb && hasMicroThumb) {
Serial.printf("[%lu] [TXT] All covers already cached\n", millis());
if (progressCallback) progressCallback(100);
return true;
}
std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found, skipping cover generation\n", millis());
return false;
}
Serial.printf("[%lu] [TXT] Generating all covers (cover:%d, thumb:%d, micro:%d)\n", millis(), !hasCover, !hasThumb,
!hasMicroThumb);
// Generate each cover type that's missing with progress updates
if (!hasCover) {
(void)generateCoverBmp();
}
if (progressCallback) progressCallback(33);
if (!hasThumb) {
(void)generateThumbBmp();
}
if (progressCallback) progressCallback(66);
if (!hasMicroThumb) {
(void)generateMicroThumbBmp();
}
if (progressCallback) progressCallback(100);
Serial.printf("[%lu] [TXT] All cover generation complete\n", millis());
return true;
}
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
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;
}