2025-12-03 22:00:29 +11:00
|
|
|
#include "Epub.h"
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
#include <FsHelpers.h>
|
2025-12-03 22:00:29 +11:00
|
|
|
#include <HardwareSerial.h>
|
2025-12-21 18:42:06 +11:00
|
|
|
#include <JpegToBmpConverter.h>
|
2025-12-30 15:09:30 +10:00
|
|
|
#include <SDCardManager.h>
|
2025-12-03 22:00:29 +11:00
|
|
|
#include <ZipFile.h>
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
#include "Epub/parsers/ContainerParser.h"
|
|
|
|
|
#include "Epub/parsers/ContentOpfParser.h"
|
2026-01-03 16:10:35 +08:00
|
|
|
#include "Epub/parsers/TocNavParser.h"
|
2025-12-13 19:36:01 +11:00
|
|
|
#include "Epub/parsers/TocNcxParser.h"
|
2025-12-12 22:13:34 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
|
|
|
|
const auto containerPath = "META-INF/container.xml";
|
|
|
|
|
size_t containerSize;
|
|
|
|
|
|
|
|
|
|
// Get file size without loading it all into heap
|
|
|
|
|
if (!getItemSize(containerPath, &containerSize)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not find or size META-INF/container.xml\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
ContainerParser containerParser(containerSize);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!containerParser.setup()) {
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Stream read (reusing your existing stream logic)
|
|
|
|
|
if (!readItemContentsToStream(containerPath, containerParser, 512)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Extract the result
|
|
|
|
|
if (containerParser.fullPath.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
*contentOpfFile = std::move(containerParser.fullPath);
|
|
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|
|
|
|
std::string contentOpfFilePath;
|
|
|
|
|
if (!findContentOpfFile(&contentOpfFilePath)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
|
|
|
|
|
|
2025-12-21 15:43:53 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str());
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
size_t contentOpfSize;
|
|
|
|
|
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!opfParser.setup()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
// Grab data from opfParser into epub
|
2025-12-24 22:36:13 +11:00
|
|
|
bookMetadata.title = opfParser.title;
|
2025-12-30 21:15:44 +10:00
|
|
|
bookMetadata.author = opfParser.author;
|
2026-01-19 17:56:26 +05:00
|
|
|
bookMetadata.language = opfParser.language;
|
2025-12-24 22:36:13 +11:00
|
|
|
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
2025-12-30 13:02:46 +01:00
|
|
|
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-17 10:49:45 +03:00
|
|
|
if (!opfParser.tocNcxPath.empty()) {
|
|
|
|
|
tocNcxItem = opfParser.tocNcxPath;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
if (!opfParser.tocNavPath.empty()) {
|
|
|
|
|
tocNavItem = opfParser.tocNavPath;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:57:04 -05:00
|
|
|
// Copy CSS files to metadata
|
|
|
|
|
bookMetadata.cssFiles = opfParser.cssFiles;
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
bool Epub::parseTocNcxFile() const {
|
2025-12-03 22:00:29 +11:00
|
|
|
// the ncx file should have been specified in the content.opf file
|
|
|
|
|
if (tocNcxItem.empty()) {
|
2025-12-08 22:39:23 +11:00
|
|
|
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 15:43:53 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str());
|
|
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile tempNcxFile;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-21 17:08:34 +11:00
|
|
|
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
|
|
|
|
tempNcxFile.close();
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto ncxSize = tempNcxFile.size();
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
if (!ncxParser.setup()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
|
2025-12-30 23:18:51 +11:00
|
|
|
tempNcxFile.close();
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
|
|
|
|
if (!ncxBuffer) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis());
|
2025-12-30 23:18:51 +11:00
|
|
|
tempNcxFile.close();
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 17:08:34 +11:00
|
|
|
while (tempNcxFile.available()) {
|
|
|
|
|
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
|
2025-12-30 23:18:51 +11:00
|
|
|
if (readSize == 0) break;
|
2025-12-21 17:08:34 +11:00
|
|
|
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
|
|
|
|
|
|
|
|
|
if (processedSize != readSize) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis());
|
|
|
|
|
free(ncxBuffer);
|
|
|
|
|
tempNcxFile.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(ncxBuffer);
|
|
|
|
|
tempNcxFile.close();
|
2025-12-30 15:09:30 +10:00
|
|
|
SdMan.remove(tmpNcxPath.c_str());
|
2025-12-21 17:08:34 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
|
2025-12-13 19:36:01 +11:00
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
bool Epub::parseTocNavFile() const {
|
|
|
|
|
// the nav file should have been specified in the content.opf file (EPUB 3)
|
|
|
|
|
if (tocNavItem.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
|
|
|
|
|
|
|
|
|
|
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
|
|
|
|
FsFile tempNavFile;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
|
|
|
|
tempNavFile.close();
|
|
|
|
|
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const auto navSize = tempNavFile.size();
|
|
|
|
|
|
2026-01-12 23:57:34 +10:00
|
|
|
// Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf
|
|
|
|
|
// and the HTMLX nav file will have hrefs relative to itself
|
|
|
|
|
const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1);
|
|
|
|
|
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
if (!navParser.setup()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
|
|
|
|
if (!navBuffer) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (tempNavFile.available()) {
|
|
|
|
|
const auto readSize = tempNavFile.read(navBuffer, 1024);
|
|
|
|
|
const auto processedSize = navParser.write(navBuffer, readSize);
|
|
|
|
|
|
|
|
|
|
if (processedSize != readSize) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
|
|
|
|
|
free(navBuffer);
|
|
|
|
|
tempNavFile.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
free(navBuffer);
|
|
|
|
|
tempNavFile.close();
|
|
|
|
|
SdMan.remove(tmpNavPath.c_str());
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:57:04 -05:00
|
|
|
bool Epub::parseCssFiles() {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cannot parse CSS, cache not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always create CssParser - needed for inline style parsing even without CSS files
|
|
|
|
|
cssParser.reset(new CssParser());
|
|
|
|
|
|
|
|
|
|
const auto& cssFiles = bookMetadataCache->coreMetadata.cssFiles;
|
|
|
|
|
if (cssFiles.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const auto& cssPath : cssFiles) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str());
|
|
|
|
|
|
|
|
|
|
// Extract CSS file to temp location
|
|
|
|
|
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
|
|
|
|
FsFile tempCssFile;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str());
|
|
|
|
|
tempCssFile.close();
|
|
|
|
|
SdMan.remove(tmpCssPath.c_str());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
tempCssFile.close();
|
|
|
|
|
|
|
|
|
|
// Parse the CSS file
|
|
|
|
|
if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis());
|
|
|
|
|
SdMan.remove(tmpCssPath.c_str());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
cssParser->loadFromStream(tempCssFile);
|
|
|
|
|
tempCssFile.close();
|
|
|
|
|
SdMan.remove(tmpCssPath.c_str());
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:57:31 -05:00
|
|
|
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(),
|
|
|
|
|
cssParser->ruleCount(), cssFiles.size(), cssParser->estimateMemoryUsage());
|
2026-01-17 17:57:04 -05:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 22:00:29 +11:00
|
|
|
// load in the meta data for the epub file
|
2025-12-30 22:18:10 +10:00
|
|
|
bool Epub::load(const bool buildIfMissing) {
|
2025-12-13 19:36:01 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Initialize spine/TOC cache
|
|
|
|
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
|
|
|
|
|
|
|
|
|
// Try to load existing cache first
|
|
|
|
|
if (bookMetadataCache->load()) {
|
2026-01-17 17:57:04 -05:00
|
|
|
// Parse CSS files from loaded cache
|
|
|
|
|
parseCssFiles();
|
2025-12-24 22:36:13 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
|
|
|
|
return true;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 22:18:10 +10:00
|
|
|
// If we didn't load from cache above and we aren't allowed to build, fail now
|
|
|
|
|
if (!buildIfMissing) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Cache doesn't exist or is invalid, build it
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis());
|
|
|
|
|
setupCacheDir();
|
2025-12-13 19:36:01 +11:00
|
|
|
|
2026-01-20 23:35:54 -08:00
|
|
|
const uint32_t indexingStart = millis();
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Begin building cache - stream entries to disk immediately
|
|
|
|
|
if (!bookMetadataCache->beginWrite()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// OPF Pass
|
2026-01-20 23:35:54 -08:00
|
|
|
const uint32_t opfStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
BookMetadataCache::BookMetadata bookMetadata;
|
|
|
|
|
if (!bookMetadataCache->beginContentOpfPass()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!parseContentOpf(bookMetadata)) {
|
2025-12-13 19:36:01 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
|
2025-12-03 22:00:29 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->endContentOpfPass()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-20 23:35:54 -08:00
|
|
|
Serial.printf("[%lu] [EBP] OPF pass completed in %lu ms\n", millis(), millis() - opfStart);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2026-01-03 16:10:35 +08:00
|
|
|
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
2026-01-20 23:35:54 -08:00
|
|
|
const uint32_t tocStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->beginTocPass()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
bool tocParsed = false;
|
|
|
|
|
|
|
|
|
|
// Try EPUB 3 nav document first (preferred)
|
|
|
|
|
if (!tocNavItem.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
|
|
|
|
|
tocParsed = parseTocNavFile();
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
2026-01-03 16:10:35 +08:00
|
|
|
|
|
|
|
|
// Fall back to NCX if nav parsing failed or wasn't available
|
|
|
|
|
if (!tocParsed && !tocNcxItem.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
|
|
|
|
|
tocParsed = parseTocNcxFile();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!tocParsed) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
|
|
|
|
|
// Continue anyway - book will work without TOC
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->endTocPass()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-20 23:35:54 -08:00
|
|
|
Serial.printf("[%lu] [EBP] TOC pass completed in %lu ms\n", millis(), millis() - tocStart);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Close the cache files
|
|
|
|
|
if (!bookMetadataCache->endWrite()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Build final book.bin
|
2026-01-20 23:35:54 -08:00
|
|
|
const uint32_t buildStart = millis();
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-20 23:35:54 -08:00
|
|
|
Serial.printf("[%lu] [EBP] buildBookBin completed in %lu ms\n", millis(), millis() - buildStart);
|
|
|
|
|
Serial.printf("[%lu] [EBP] Total indexing completed in %lu ms\n", millis(), millis() - indexingStart);
|
2025-12-18 12:49:14 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache->cleanupTmpFiles()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis());
|
|
|
|
|
}
|
2025-12-18 12:49:14 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
// Reload the cache from disk so it's in the correct state
|
|
|
|
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
|
|
|
|
if (!bookMetadataCache->load()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis());
|
|
|
|
|
return false;
|
2025-12-18 12:49:14 +01:00
|
|
|
}
|
2025-12-21 04:38:51 +01:00
|
|
|
|
2026-01-17 17:57:04 -05:00
|
|
|
// Parse CSS files after cache reload
|
|
|
|
|
parseCssFiles();
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
|
|
|
|
return true;
|
2025-12-18 12:49:14 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-12 22:13:34 +11:00
|
|
|
bool Epub::clearCache() const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.exists(cachePath.c_str())) {
|
2025-12-12 22:13:34 +11:00
|
|
|
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.removeDir(cachePath.c_str())) {
|
2025-12-12 22:13:34 +11:00
|
|
|
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [EPB] Cache cleared successfully\n", millis());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
|
|
|
|
void Epub::setupCacheDir() const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (SdMan.exists(cachePath.c_str())) {
|
2025-12-03 22:00:29 +11:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
SdMan.mkdir(cachePath.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getCachePath() const { return cachePath; }
|
|
|
|
|
|
|
|
|
|
const std::string& Epub::getPath() const { return filepath; }
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
const std::string& Epub::getTitle() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.title;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-30 21:15:44 +10:00
|
|
|
const std::string& Epub::getAuthor() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.author;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 17:56:26 +05:00
|
|
|
const std::string& Epub::getLanguage() const {
|
|
|
|
|
static std::string blank;
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return blank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->coreMetadata.language;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 10:55:47 +01:00
|
|
|
std::string Epub::getCoverBmpPath(bool cropped) const {
|
2026-01-23 23:53:24 +01:00
|
|
|
return cropped ? (cachePath + "/cover_crop.bmp") : (cachePath + "/cover_fit.bmp");
|
2026-01-12 10:55:47 +01:00
|
|
|
}
|
2025-12-21 18:42:06 +11:00
|
|
|
|
2026-01-12 10:55:47 +01:00
|
|
|
bool Epub::generateCoverBmp(bool cropped) const {
|
2025-12-21 18:42:06 +11:00
|
|
|
// Already generated, return true
|
2026-01-12 10:55:47 +01:00
|
|
|
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
|
2025-12-21 18:42:06 +11:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
2025-12-21 18:42:06 +11:00
|
|
|
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
|
|
|
|
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
2026-01-27 07:50:37 -05:00
|
|
|
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit");
|
2025-12-23 14:14:10 +11:00
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
2025-12-21 18:42:06 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:53:24 +01:00
|
|
|
// Get JPEG dimensions to calculate target dimensions for FIT/CROP
|
|
|
|
|
int jpegWidth, jpegHeight;
|
|
|
|
|
if (!JpegToBmpConverter::getJpegDimensions(coverJpg, jpegWidth, jpegHeight)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to get JPEG dimensions\n", millis());
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate target dimensions based on FIT/CROP mode
|
|
|
|
|
// FIT: ancho fijo 480px, alto proporcional = 480 * (jpegHeight / jpegWidth)
|
|
|
|
|
// CROP: alto fijo 800px, ancho proporcional = 800 * (jpegWidth / jpegHeight)
|
|
|
|
|
int targetWidth, targetHeight;
|
|
|
|
|
if (cropped) {
|
|
|
|
|
// CROP mode: height = 800, width proportional
|
|
|
|
|
targetHeight = 800;
|
|
|
|
|
targetWidth = (800 * jpegWidth) / jpegHeight;
|
|
|
|
|
} else {
|
|
|
|
|
// FIT mode: width = 480, height proportional
|
|
|
|
|
targetWidth = 480;
|
|
|
|
|
targetHeight = (480 * jpegHeight) / jpegWidth;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [EBP] Calculated %s dimensions: %dx%d (original JPEG: %dx%d)\n", millis(),
|
|
|
|
|
cropped ? "CROP" : "FIT", targetWidth, targetHeight, jpegWidth, jpegHeight);
|
|
|
|
|
|
2025-12-30 15:09:30 +10:00
|
|
|
FsFile coverBmp;
|
2026-01-12 10:55:47 +01:00
|
|
|
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
2025-12-23 14:14:10 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-23 23:53:24 +01:00
|
|
|
const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight);
|
2025-12-21 18:42:06 +11:00
|
|
|
coverJpg.close();
|
|
|
|
|
coverBmp.close();
|
2025-12-30 15:09:30 +10:00
|
|
|
SdMan.remove(coverJpgTempPath.c_str());
|
2025-12-21 18:42:06 +11:00
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
|
2026-01-12 10:55:47 +01:00
|
|
|
SdMan.remove(getCoverBmpPath(cropped).c_str());
|
2025-12-21 18:42:06 +11:00
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
|
|
|
|
|
return success;
|
|
|
|
|
} else {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,
Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.
* **What changes are included?**
- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering
## Additional Context
* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 19:24:02 +09:00
|
|
|
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
|
|
|
|
|
|
|
|
|
|
bool Epub::generateThumbBmp() const {
|
|
|
|
|
// Already generated, return true
|
|
|
|
|
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
|
|
|
|
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
|
|
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
|
|
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
|
|
|
|
|
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile thumbBmp;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
|
|
|
|
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
|
|
|
|
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();
|
|
|
|
|
SdMan.remove(coverJpgTempPath.c_str());
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
|
|
|
|
|
SdMan.remove(getThumbBmpPath().c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
|
|
|
|
|
success ? "yes" : "no");
|
|
|
|
|
return success;
|
|
|
|
|
} else {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 02:01:53 -05:00
|
|
|
std::string Epub::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; }
|
|
|
|
|
|
|
|
|
|
bool Epub::generateMicroThumbBmp() const {
|
|
|
|
|
// Already generated, return true
|
|
|
|
|
if (SdMan.exists(getMicroThumbBmpPath().c_str())) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cannot generate micro thumb BMP, cache not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] No known cover image for micro thumbnail\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
|
|
|
|
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generating micro thumb BMP from JPG cover image\n", millis());
|
|
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
|
|
|
|
|
// Check if temp JPEG already exists (from generateAllCovers), otherwise extract it
|
|
|
|
|
bool needsCleanup = false;
|
|
|
|
|
if (!SdMan.exists(coverJpgTempPath.c_str())) {
|
|
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
needsCleanup = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FsFile microThumbBmp;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) {
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Use very small target size for Recent Books list (45x60 pixels)
|
|
|
|
|
// Generate 1-bit BMP for fast rendering
|
|
|
|
|
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 (needsCleanup) {
|
|
|
|
|
SdMan.remove(coverJpgTempPath.c_str());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to generate micro thumb BMP from JPG cover image\n", millis());
|
|
|
|
|
SdMan.remove(getMicroThumbBmpPath().c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated micro thumb BMP from JPG cover image, success: %s\n", millis(),
|
|
|
|
|
success ? "yes" : "no");
|
|
|
|
|
return success;
|
|
|
|
|
} else {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping micro thumbnail\n", millis());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) const {
|
|
|
|
|
// Check if all covers already exist - quick exit if nothing to do
|
|
|
|
|
const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str());
|
|
|
|
|
const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str());
|
|
|
|
|
const bool hasCoverFit = SdMan.exists(getCoverBmpPath(false).c_str());
|
|
|
|
|
const bool hasCoverCrop = SdMan.exists(getCoverBmpPath(true).c_str());
|
|
|
|
|
|
|
|
|
|
if (hasThumb && hasMicroThumb && hasCoverFit && hasCoverCrop) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] All covers already cached\n", millis());
|
|
|
|
|
if (progressCallback) progressCallback(100);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cannot generate covers, cache not loaded\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
|
|
|
|
if (coverImageHref.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only process JPG/JPEG covers
|
|
|
|
|
if (coverImageHref.substr(coverImageHref.length() - 4) != ".jpg" &&
|
|
|
|
|
coverImageHref.substr(coverImageHref.length() - 5) != ".jpeg") {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping all cover generation\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generating all covers (thumb:%d, micro:%d, fit:%d, crop:%d)\n", millis(), !hasThumb,
|
|
|
|
|
!hasMicroThumb, !hasCoverFit, !hasCoverCrop);
|
|
|
|
|
|
|
|
|
|
// Extract JPEG once to temp file
|
|
|
|
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
|
|
|
|
{
|
|
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to create temp cover file\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get JPEG dimensions once for FIT/CROP calculations
|
|
|
|
|
int jpegWidth = 0, jpegHeight = 0;
|
|
|
|
|
{
|
|
|
|
|
FsFile coverJpg;
|
|
|
|
|
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
|
|
|
|
JpegToBmpConverter::getJpegDimensions(coverJpg, jpegWidth, jpegHeight);
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Progress tracking: 4 covers = 25% each
|
|
|
|
|
// Helper to create sub-progress callback that maps 0-100% to a portion of overall progress
|
|
|
|
|
auto makeSubProgress = [&progressCallback](int startPercent, int endPercent) {
|
|
|
|
|
if (!progressCallback) return std::function<void(int)>(nullptr);
|
|
|
|
|
return std::function<void(int)>([&progressCallback, startPercent, endPercent](int subPercent) {
|
|
|
|
|
const int overallProgress = startPercent + (subPercent * (endPercent - startPercent)) / 100;
|
|
|
|
|
progressCallback(overallProgress);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Generate thumb (240x400, 1-bit) if missing - progress 0-25%
|
|
|
|
|
if (!hasThumb) {
|
|
|
|
|
FsFile coverJpg, thumbBmp;
|
|
|
|
|
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
|
|
|
|
SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
|
|
|
|
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, makeSubProgress(0, 25));
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
thumbBmp.close();
|
|
|
|
|
if (!success) {
|
|
|
|
|
SdMan.remove(getThumbBmpPath().c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated thumb: %s\n", millis(), success ? "yes" : "no");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (progressCallback) progressCallback(25);
|
|
|
|
|
|
|
|
|
|
// Generate micro thumb (45x60, 1-bit) if missing - progress 25-50%
|
|
|
|
|
if (!hasMicroThumb) {
|
|
|
|
|
FsFile coverJpg, microThumbBmp;
|
|
|
|
|
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
|
|
|
|
SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) {
|
|
|
|
|
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, makeSubProgress(25, 50));
|
|
|
|
|
coverJpg.close();
|
|
|
|
|
microThumbBmp.close();
|
|
|
|
|
if (!success) {
|
|
|
|
|
SdMan.remove(getMicroThumbBmpPath().c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated micro thumb: %s\n", millis(), success ? "yes" : "no");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (progressCallback) progressCallback(50);
|
|
|
|
|
|
|
|
|
|
// Generate cover_fit (480xProportional, 2-bit) if missing - progress 50-75%
|
|
|
|
|
if (!hasCoverFit && jpegWidth > 0 && jpegHeight > 0) {
|
|
|
|
|
FsFile coverJpg, coverBmp;
|
|
|
|
|
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
|
|
|
|
SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) {
|
|
|
|
|
const int targetWidth = 480;
|
|
|
|
|
const int targetHeight = (480 * jpegHeight) / jpegWidth;
|
2026-01-28 15:57:31 -05:00
|
|
|
const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
|
|
|
|
|
targetHeight, makeSubProgress(50, 75));
|
2026-01-24 02:01:53 -05:00
|
|
|
coverJpg.close();
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
if (!success) {
|
|
|
|
|
SdMan.remove(getCoverBmpPath(false).c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated cover_fit: %s\n", millis(), success ? "yes" : "no");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (progressCallback) progressCallback(75);
|
|
|
|
|
|
|
|
|
|
// Generate cover_crop (Proportionalx800, 2-bit) if missing - progress 75-100%
|
|
|
|
|
if (!hasCoverCrop && jpegWidth > 0 && jpegHeight > 0) {
|
|
|
|
|
FsFile coverJpg, coverBmp;
|
|
|
|
|
if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) &&
|
|
|
|
|
SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) {
|
|
|
|
|
const int targetHeight = 800;
|
|
|
|
|
const int targetWidth = (800 * jpegWidth) / jpegHeight;
|
2026-01-28 15:57:31 -05:00
|
|
|
const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
|
|
|
|
|
targetHeight, makeSubProgress(75, 100));
|
2026-01-24 02:01:53 -05:00
|
|
|
coverJpg.close();
|
|
|
|
|
coverBmp.close();
|
|
|
|
|
if (!success) {
|
|
|
|
|
SdMan.remove(getCoverBmpPath(true).c_str());
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [EBP] Generated cover_crop: %s\n", millis(), success ? "yes" : "no");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (progressCallback) progressCallback(100);
|
|
|
|
|
|
|
|
|
|
// Clean up temp JPEG
|
|
|
|
|
SdMan.remove(coverJpgTempPath.c_str());
|
|
|
|
|
Serial.printf("[%lu] [EBP] All cover generation complete\n", millis());
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (itemHref.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-03 22:00:29 +11:00
|
|
|
|
2025-12-29 20:17:29 +10:00
|
|
|
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
2025-12-03 22:00:29 +11:00
|
|
|
if (!content) {
|
2025-12-08 22:39:23 +11:00
|
|
|
Serial.printf("[%lu] [EBP] Failed to read item %s\n", millis(), path.c_str());
|
2025-12-03 22:00:29 +11:00
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return content;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 00:39:17 +11:00
|
|
|
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
2025-12-30 15:09:30 +10:00
|
|
|
if (itemHref.empty()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-29 20:17:29 +10:00
|
|
|
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-13 19:36:01 +11:00
|
|
|
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
2025-12-23 14:14:10 +11:00
|
|
|
const std::string path = FsHelpers::normalisePath(itemHref);
|
2025-12-29 20:17:29 +10:00
|
|
|
return ZipFile(filepath).getInflatedFileSize(path.c_str(), size);
|
2025-12-13 19:36:01 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getSpineItemsCount() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
2025-12-21 03:36:30 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineCount();
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
|
|
|
|
|
|
|
|
|
|
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
|
|
|
|
|
return {};
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
|
|
|
|
|
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
|
2025-12-08 22:39:23 +11:00
|
|
|
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineEntry(0);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getSpineEntry(spineIndex);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
|
|
|
|
|
return {};
|
2025-12-21 03:36:30 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
|
|
|
|
|
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
2025-12-23 14:14:10 +11:00
|
|
|
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex);
|
2025-12-24 22:36:13 +11:00
|
|
|
return {};
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return bookMetadataCache->getTocEntry(tocIndex);
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getTocItemsCount() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bookMetadataCache->getTocCount();
|
|
|
|
|
}
|
2025-12-03 22:00:29 +11:00
|
|
|
|
|
|
|
|
// work out the section index for a toc index
|
|
|
|
|
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis());
|
2025-12-19 13:23:23 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
|
|
|
|
|
return 0;
|
2025-12-19 13:23:23 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
|
|
|
|
if (spineIndex < 0) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex);
|
|
|
|
|
return 0;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
return spineIndex;
|
2025-12-03 22:00:29 +11:00
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-24 22:36:13 +11:00
|
|
|
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
|
|
|
|
|
|
2025-12-19 13:28:36 +01:00
|
|
|
size_t Epub::getBookSize() const {
|
2025-12-24 22:36:13 +11:00
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
|
2025-12-19 13:28:36 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
|
|
|
|
|
}
|
2025-12-17 13:05:24 +01:00
|
|
|
|
2025-12-30 13:02:46 +01:00
|
|
|
int Epub::getSpineIndexForTextReference() const {
|
|
|
|
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
|
|
|
|
Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(),
|
|
|
|
|
bookMetadataCache->coreMetadata.coverItemHref.size(),
|
|
|
|
|
bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
|
|
|
|
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
|
|
|
|
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
|
|
|
|
|
|
|
|
|
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
|
|
|
|
// there was no textReference in epub, so we return 0 (the first chapter)
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loop through spine items to get the correct index matching the text href
|
|
|
|
|
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
|
|
|
|
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
|
|
|
|
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(),
|
|
|
|
|
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i);
|
|
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// This should not happen, as we checked for empty textReferenceHref earlier
|
|
|
|
|
Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 06:55:35 -05:00
|
|
|
// Calculate progress in book (returns 0.0-1.0)
|
|
|
|
|
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
|
2025-12-24 22:36:13 +11:00
|
|
|
const size_t bookSize = getBookSize();
|
2025-12-19 13:28:36 +01:00
|
|
|
if (bookSize == 0) {
|
2026-01-19 06:55:35 -05:00
|
|
|
return 0.0f;
|
2025-12-19 13:28:36 +01:00
|
|
|
}
|
2025-12-24 22:36:13 +11:00
|
|
|
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
|
|
|
|
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
|
2026-01-19 06:55:35 -05:00
|
|
|
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
|
|
|
|
|
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
|
|
|
|
return totalProgress / static_cast<float>(bookSize);
|
2025-12-17 13:05:24 +01:00
|
|
|
}
|