mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
## 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.
192 lines
5.7 KiB
C++
192 lines
5.7 KiB
C++
#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;
|
|
}
|
|
|
|
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;
|
|
}
|