#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; } 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& 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; } 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; }