#include "Txt.h" #include #include #include #include #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 (!Storage.exists(filepath.c_str())) { LOG_ERR("TXT", "File does not exist: %s", filepath.c_str()); return false; } FsFile file; if (!Storage.openFileForRead("TXT", filepath, file)) { LOG_ERR("TXT", "Failed to open file: %s", filepath.c_str()); return false; } fileSize = file.size(); file.close(); loaded = true; LOG_DBG("TXT", "Loaded TXT file: %s (%zu bytes)", 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 or .md extension if (FsHelpers::hasTxtExtension(filename)) { filename = filename.substr(0, filename.length() - 4); } else if (FsHelpers::hasMarkdownExtension(filename)) { filename = filename.substr(0, filename.length() - 3); } return filename; } void Txt::setupCacheDir() const { if (!Storage.exists(cacheBasePath.c_str())) { Storage.mkdir(cacheBasePath.c_str()); } if (!Storage.exists(cachePath.c_str())) { Storage.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 (Storage.exists(coverPath.c_str())) { LOG_DBG("TXT", "Found matching cover image: %s", 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 (Storage.exists(coverPath.c_str())) { LOG_DBG("TXT", "Found fallback cover image: %s", coverPath.c_str()); return coverPath; } } } return ""; } std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } bool Txt::generateCoverBmp() const { // Already generated, return true if (Storage.exists(getCoverBmpPath().c_str())) { return true; } std::string coverImagePath = findCoverImage(); if (coverImagePath.empty()) { LOG_DBG("TXT", "No cover image found for TXT file"); return false; } // Setup cache directory setupCacheDir(); if (FsHelpers::hasBmpExtension(coverImagePath)) { // Copy BMP file to cache LOG_DBG("TXT", "Copying BMP cover image to cache"); FsFile src, dst; if (!Storage.openFileForRead("TXT", coverImagePath, src)) { return false; } if (!Storage.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(); LOG_DBG("TXT", "Copied BMP cover to cache"); return true; } else if (FsHelpers::hasJpgExtension(coverImagePath)) { // Convert JPG/JPEG to BMP (same approach as Epub) LOG_DBG("TXT", "Generating BMP from JPG cover image"); FsFile coverJpg, coverBmp; if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) { return false; } if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { coverJpg.close(); return false; } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); coverJpg.close(); coverBmp.close(); if (!success) { LOG_ERR("TXT", "Failed to generate BMP from JPG cover image"); Storage.remove(getCoverBmpPath().c_str()); } else { LOG_DBG("TXT", "Generated BMP from JPG cover image"); } return success; } else if (FsHelpers::hasPngExtension(coverImagePath)) { LOG_DBG("TXT", "Generating BMP from PNG cover image"); FsFile coverPng, coverBmp; if (!Storage.openFileForRead("TXT", coverImagePath, coverPng)) { return false; } if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { coverPng.close(); return false; } const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp); coverPng.close(); coverBmp.close(); if (!success) { LOG_ERR("TXT", "Failed to generate BMP from PNG cover image"); Storage.remove(getCoverBmpPath().c_str()); } else { LOG_DBG("TXT", "Generated BMP from PNG cover image"); } return success; } LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG/PNG)"); return false; } std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; } std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } std::string Txt::getThumbBmpPath(int width, int height) const { return cachePath + "/thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp"; } bool Txt::generateThumbBmp(int height) const { const std::string destPath = getThumbBmpPath(height); if (Storage.exists(destPath.c_str())) return true; const int width = static_cast(height * 0.6f); if (!generateThumbBmp(width, height)) return false; const std::string srcPath = getThumbBmpPath(width, height); Storage.rename(srcPath.c_str(), destPath.c_str()); return Storage.exists(destPath.c_str()); } bool Txt::generateThumbBmp(int width, int height) const { const std::string thumbPath = getThumbBmpPath(width, height); if (Storage.exists(thumbPath.c_str())) return true; setupCacheDir(); FsFile thumbBmp; if (!Storage.openFileForWrite("TXT", thumbPath, thumbBmp)) return false; const uint32_t rowSize = (static_cast(width) + 31) / 32 * 4; BmpHeader bmpHeader; createBmpHeader(&bmpHeader, width, height, BmpRowOrder::TopDown); thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(BmpHeader)); uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { thumbBmp.close(); Storage.remove(thumbPath.c_str()); return false; } // Matches the Lyra "no cover" placeholder: 1px border, white top third, black bottom two thirds. // Book icon (32x32, 1=white/transparent, 0=black) centered in the white area. // In 1-bit BMP: 1=white, 0=black. static const uint8_t kIcon[] = { 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFE, 0x1F, 0xE0, 0x00, 0x02, 0x1F, 0xE0, 0x00, 0x03, 0x1F, 0xE0, 0x00, 0x03, 0x1F, 0xF0, 0x00, 0x01, 0x1F, 0xF0, 0x00, 0x01, 0x1F, 0xF0, 0x00, 0x01, 0x9F, 0xF8, 0x00, 0x00, 0x9F, 0xF8, 0x00, 0x00, 0xDF, 0xFC, 0x00, 0x00, 0x6F, 0xFE, 0x00, 0x00, 0x3F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0x80, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x3F, 0xFC, 0x00, 0x00, 0x6F, 0xF8, 0x00, 0x00, 0xDF, 0xF8, 0x00, 0x00, 0x9F, 0xF0, 0x00, 0x01, 0x9F, 0xF0, 0x00, 0x01, 0x1F, 0xE0, 0x00, 0x01, 0x1F, 0xE0, 0x00, 0x03, 0x1F, 0xE0, 0x00, 0x02, 0x1F, 0xE0, 0x00, 0x02, 0x1F, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x1F}; static constexpr int kIconSize = 32; static constexpr int kIconStride = 4; // bytes per icon row (32 bits) const int splitY = height / 3; // white above, black below const int iconX = (width - kIconSize) / 2; const int iconY = (splitY - kIconSize) / 2; for (int y = 0; y < height; y++) { const bool blackRegion = (y >= splitY); memset(rowBuffer, blackRegion ? 0x00 : 0xFF, rowSize); // 1px border if (y == 0 || y == height - 1) { memset(rowBuffer, 0x00, rowSize); } else { // Left and right border pixels rowBuffer[0] &= 0x7F; // clear MSB (x=0) rowBuffer[(width - 1) / 8] &= ~(0x80u >> ((width - 1) % 8)); // clear x=width-1 // Overlay icon row if within icon bounds (icon is on white area) const int iconRow = y - iconY; if (!blackRegion && iconRow >= 0 && iconRow < kIconSize && iconX >= 0 && iconX + kIconSize <= width) { // Icon format: 0=dark pixel, 1=white/transparent. In BMP: 0=black, 1=white. // The icon pixels (0=dark) should clear bits in the white BMP region. for (int ix = 0; ix < kIconSize; ix++) { const int iconByte = iconRow * kIconStride + ix / 8; const int iconBit = 7 - (ix % 8); const bool iconDark = !((kIcon[iconByte] >> iconBit) & 1); if (iconDark) { const int bx = iconX + ix; rowBuffer[bx / 8] &= ~(0x80u >> (bx % 8)); } } } } thumbBmp.write(rowBuffer, rowSize); } free(rowBuffer); thumbBmp.close(); LOG_DBG("TXT", "Generated surrogate thumb BMP (%dx%d): %s", width, height, thumbPath.c_str()); return true; } bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { if (!loaded) { return false; } FsFile file; if (!Storage.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; }