#include "BookMetadataCache.h" #include #include #include #include #include #include #include "FsHelpers.h" namespace { constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-composed constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; // Buffer size for the buildBookBin streams. 3 buffers x 4KB, transient (freed on // return); 4KB = 8 SD sectors per transfer, enough to stop the sector-cache thrash. constexpr size_t BUILD_IO_BUFFER_SIZE = 4096; // Entry (de)serializers, templated so they run over HalFile and the Buffered* // wrappers alike (two instantiations each -- a few hundred bytes of flash, in // exchange for the build path streaming at SD speed instead of per-pod). template uint32_t writeSpineEntryTo(F& file, const BookMetadataCache::SpineEntry& entry) { const uint32_t pos = file.position(); serialization::writeString(file, entry.href); serialization::writePod(file, entry.cumulativeSize); serialization::writePod(file, entry.tocIndex); return pos; } template uint32_t writeTocEntryTo(F& file, const BookMetadataCache::TocEntry& entry) { const uint32_t pos = file.position(); serialization::writeString(file, entry.title); serialization::writeString(file, entry.href); serialization::writeString(file, entry.anchor); serialization::writePod(file, entry.level); serialization::writePod(file, entry.spineIndex); return pos; } template BookMetadataCache::SpineEntry readSpineEntryFrom(F& file) { BookMetadataCache::SpineEntry entry; serialization::readString(file, entry.href); serialization::readPod(file, entry.cumulativeSize); serialization::readPod(file, entry.tocIndex); return entry; } template BookMetadataCache::TocEntry readTocEntryFrom(F& file) { BookMetadataCache::TocEntry entry; serialization::readString(file, entry.title); serialization::readString(file, entry.href); serialization::readString(file, entry.anchor); serialization::readPod(file, entry.level); serialization::readPod(file, entry.spineIndex); return entry; } } // namespace /* ============= WRITING / BUILDING FUNCTIONS ================ */ bool BookMetadataCache::beginWrite() { buildMode = true; spineCount = 0; tocCount = 0; LOG_DBG("BMC", "Entering write mode"); return true; } bool BookMetadataCache::beginContentOpfPass() { LOG_DBG("BMC", "Beginning content opf pass"); // Open spine file for writing if (!Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile)) { return false; } // Wrapper OOM is fine: createSpineEntry falls back to unbuffered writes. passOut = makeUniqueNoThrow(spineFile, BUILD_IO_BUFFER_SIZE); return true; } bool BookMetadataCache::endContentOpfPass() { const bool flushed = !passOut || passOut->flush(); passOut.reset(); // Explicit close() required: member variable persists beyond function scope spineFile.close(); if (!flushed) { LOG_ERR("BMC", "Failed writing spine tmp file"); } return flushed; } bool BookMetadataCache::beginTocPass() { LOG_DBG("BMC", "Beginning toc pass"); if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { return false; } if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { // Explicit close() required: member variable persists beyond function scope spineFile.close(); return false; } if (spineCount >= LARGE_SPINE_THRESHOLD) { spineHrefIndex.clear(); spineHrefIndex.resize(spineCount); spineFile.seek(0); for (int i = 0; i < spineCount; i++) { auto entry = readSpineEntry(spineFile); SpineHrefIndexEntry idx; idx.hrefHash = fnvHash64(entry.href); idx.hrefLen = static_cast(entry.href.size()); idx.spineIndex = static_cast(i); spineHrefIndex[i] = idx; } std::sort(spineHrefIndex.begin(), spineHrefIndex.end(), [](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) { return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen); }); spineFile.seek(0); useSpineHrefIndex = true; LOG_DBG("BMC", "Using fast index for %d spine items", spineCount); } else { useSpineHrefIndex = false; } // Wrapper OOM is fine: createTocEntry falls back to unbuffered writes. passOut = makeUniqueNoThrow(tocFile, BUILD_IO_BUFFER_SIZE); return true; } bool BookMetadataCache::endTocPass() { const bool flushed = !passOut || passOut->flush(); passOut.reset(); if (!flushed) { LOG_ERR("BMC", "Failed writing toc tmp file"); } // Explicit close() required: member variables persist beyond function scope tocFile.close(); spineFile.close(); spineHrefIndex.clear(); spineHrefIndex.shrink_to_fit(); useSpineHrefIndex = false; return flushed; } bool BookMetadataCache::endWrite() { if (!buildMode) { LOG_DBG("BMC", "endWrite called but not in build mode"); return false; } buildMode = false; LOG_DBG("BMC", "Wrote %d spine, %d TOC entries", spineCount, tocCount); return true; } bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) { // Open all three files, writing to meta, reading from spine and toc if (!Storage.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) { return false; } if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { // Explicit close() required: member variable persists beyond function scope bookFile.close(); return false; } if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); return false; } // Buffered streams for the whole build: every access below is sequential per // file, but interleaved ACROSS files, which thrashes SdFat's single shared // sector cache when unbuffered (one 512B SD transaction per 4-byte pod -- // measured 31s for a 1,732-spine omnibus). Three 4KB buffers, freed on return. serialization::BufferedFileWriter bookOut(bookFile, BUILD_IO_BUFFER_SIZE); serialization::BufferedFileReader spineIn(spineFile, BUILD_IO_BUFFER_SIZE); serialization::BufferedFileReader tocIn(tocFile, BUILD_IO_BUFFER_SIZE); constexpr uint32_t headerASize = sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount); const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() + metadata.coverItemHref.size() + metadata.textReferenceHref.size() + sizeof(uint32_t) * 5; const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount; const uint32_t lutOffset = headerASize + metadataSize; // Header A serialization::writePod(bookOut, BOOK_CACHE_VERSION); serialization::writePod(bookOut, lutOffset); serialization::writePod(bookOut, spineCount); serialization::writePod(bookOut, tocCount); // Metadata serialization::writeString(bookOut, metadata.title); serialization::writeString(bookOut, metadata.author); serialization::writeString(bookOut, metadata.language); serialization::writeString(bookOut, metadata.coverItemHref); serialization::writeString(bookOut, metadata.textReferenceHref); // Loop through spine entries, writing LUT positions spineIn.seek(0); for (int i = 0; i < spineCount; i++) { const uint32_t pos = spineIn.position(); readSpineEntryFrom(spineIn); serialization::writePod(bookOut, pos + lutOffset + lutSize); } // Total size of the spine tmp file: entries land in book.bin after the toc LUT // and the full spine block, so toc LUT positions are offset by it. const auto spineBytes = static_cast(spineIn.position()); // Loop through toc entries, writing LUT positions tocIn.seek(0); for (int i = 0; i < tocCount; i++) { const uint32_t pos = tocIn.position(); readTocEntryFrom(tocIn); serialization::writePod(bookOut, pos + lutOffset + lutSize + spineBytes); } // LUTs complete // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin // Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m)) std::deque spineToTocIndex(spineCount, -1); tocIn.seek(0); for (int j = 0; j < tocCount; j++) { auto tocEntry = readTocEntryFrom(tocIn); if (tocEntry.spineIndex >= 0 && tocEntry.spineIndex < spineCount) { if (spineToTocIndex[tocEntry.spineIndex] == -1) { spineToTocIndex[tocEntry.spineIndex] = static_cast(j); } } } ZipFile zip(epubPath); // Pre-open zip file to speed up size calculations if (!zip.open()) { LOG_ERR("BMC", "Could not open EPUB zip for size calculations"); // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); tocFile.close(); return false; } // NOTE: We intentionally skip calling loadAllFileStatSlims() here. // For large EPUBs (2000+ chapters), pre-loading all ZIP central directory entries // into memory causes OOM crashes on ESP32-C3's limited ~380KB RAM. // Instead, for large books we use a one-pass batch lookup that scans the ZIP // central directory once and matches against spine targets using hash comparison. // This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion. // See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134 std::deque spineSizes; bool useBatchSizes = false; if (spineCount >= LARGE_SPINE_THRESHOLD) { LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount); std::deque targets; targets.resize(spineCount); spineIn.seek(0); for (int i = 0; i < spineCount; i++) { auto entry = readSpineEntryFrom(spineIn); std::string path = FsHelpers::normalisePath(entry.href); ZipFile::SizeTarget t; t.hash = ZipFile::fnvHash64(path.c_str(), path.size()); t.len = static_cast(path.size()); t.index = static_cast(i); targets[i] = t; } std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) { return a.hash < b.hash || (a.hash == b.hash && a.len < b.len); }); spineSizes.resize(spineCount, 0); int matched = zip.fillUncompressedSizes(targets, spineSizes); LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount); targets.clear(); targets.shrink_to_fit(); useBatchSizes = true; } uint32_t cumSize = 0; spineIn.seek(0); int lastSpineTocIndex = -1; for (int i = 0; i < spineCount; i++) { auto spineEntry = readSpineEntryFrom(spineIn); spineEntry.tocIndex = spineToTocIndex[i]; // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs // Logging here is for debugging if (spineEntry.tocIndex == -1) { LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i, spineEntry.href.c_str()); spineEntry.tocIndex = lastSpineTocIndex; } lastSpineTocIndex = spineEntry.tocIndex; size_t itemSize = 0; if (useBatchSizes) { itemSize = spineSizes[i]; if (itemSize == 0) { const std::string path = FsHelpers::normalisePath(spineEntry.href); if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str()); } } } else { const std::string path = FsHelpers::normalisePath(spineEntry.href); if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str()); } } cumSize += itemSize; spineEntry.cumulativeSize = cumSize; // Write out spine data to book.bin writeSpineEntryTo(bookOut, spineEntry); } // Close opened zip file zip.close(); // Loop through toc entries from toc file writing to book.bin tocIn.seek(0); for (int i = 0; i < tocCount; i++) { auto tocEntry = readTocEntryFrom(tocIn); writeTocEntryTo(bookOut, tocEntry); } const bool written = bookOut.flush(); // Explicit close() required: member variables persist beyond function scope bookFile.close(); spineFile.close(); tocFile.close(); if (!written) { // A short write (card full/removed) would leave a truncated book.bin that // still passes the version check on load; remove it so the next open rebuilds. LOG_ERR("BMC", "Failed writing book.bin, removing truncated file"); Storage.remove((cachePath + bookBinFile).c_str()); return false; } LOG_DBG("BMC", "Successfully built book.bin"); return true; } bool BookMetadataCache::cleanupTmpFiles() const { const auto spineBinFile = cachePath + tmpSpineBinFile; if (Storage.exists(spineBinFile.c_str())) { Storage.remove(spineBinFile.c_str()); } const auto tocBinFile = cachePath + tmpTocBinFile; if (Storage.exists(tocBinFile.c_str())) { Storage.remove(tocBinFile.c_str()); } return true; } uint32_t BookMetadataCache::writeSpineEntry(HalFile& file, const SpineEntry& entry) const { return writeSpineEntryTo(file, entry); } uint32_t BookMetadataCache::writeTocEntry(HalFile& file, const TocEntry& entry) const { return writeTocEntryTo(file, entry); } // Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called // this is because in this function we're marking positions of the items void BookMetadataCache::createSpineEntry(const std::string& href) { if (!buildMode || !spineFile) { LOG_DBG("BMC", "createSpineEntry called but not in build mode"); return; } const SpineEntry entry(href, 0, -1); if (passOut) { writeSpineEntryTo(*passOut, entry); } else { writeSpineEntry(spineFile, entry); } spineCount++; } void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, const uint8_t level) { if (!buildMode || !tocFile || !spineFile) { LOG_DBG("BMC", "createTocEntry called but not in build mode"); return; } int16_t spineIndex = -1; if (useSpineHrefIndex) { uint64_t targetHash = fnvHash64(href); uint16_t targetLen = static_cast(href.size()); auto it = std::lower_bound(spineHrefIndex.begin(), spineHrefIndex.end(), SpineHrefIndexEntry{targetHash, targetLen, 0}, [](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) { return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen); }); while (it != spineHrefIndex.end() && it->hrefHash == targetHash && it->hrefLen == targetLen) { spineIndex = it->spineIndex; break; } if (spineIndex == -1) { LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str()); } } else { spineFile.seek(0); for (int i = 0; i < spineCount; i++) { auto spineEntry = readSpineEntry(spineFile); if (spineEntry.href == href) { spineIndex = static_cast(i); break; } } if (spineIndex == -1) { LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str()); } } // Compose the title to NFC at index time so the cache stores precomposed glyphs; // device fonts have no combining-mark positioning, so NFD titles render broken. const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex); if (passOut) { writeTocEntryTo(*passOut, entry); } else { writeTocEntry(tocFile, entry); } tocCount++; } /* ============= READING / LOADING FUNCTIONS ================ */ bool BookMetadataCache::load() { if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { return false; } uint8_t version; serialization::readPod(bookFile, version); if (version != BOOK_CACHE_VERSION) { LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version); // Explicit close() required: member variable persists beyond function scope bookFile.close(); return false; } serialization::readPod(bookFile, lutOffset); serialization::readPod(bookFile, spineCount); serialization::readPod(bookFile, tocCount); serialization::readString(bookFile, coreMetadata.title); serialization::readString(bookFile, coreMetadata.author); serialization::readString(bookFile, coreMetadata.language); serialization::readString(bookFile, coreMetadata.coverItemHref); serialization::readString(bookFile, coreMetadata.textReferenceHref); loaded = true; LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount); return true; } BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { if (!loaded) { LOG_ERR("BMC", "getSpineEntry called but cache not loaded"); return {}; } if (index < 0 || index >= static_cast(spineCount)) { LOG_ERR("BMC", "getSpineEntry index %d out of range", index); return {}; } // Seek to spine LUT item, read from LUT and get out data bookFile.seek(lutOffset + sizeof(uint32_t) * index); uint32_t spineEntryPos; serialization::readPod(bookFile, spineEntryPos); bookFile.seek(spineEntryPos); return readSpineEntry(bookFile); } BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { if (!loaded) { LOG_ERR("BMC", "getTocEntry called but cache not loaded"); return {}; } if (index < 0 || index >= static_cast(tocCount)) { LOG_ERR("BMC", "getTocEntry index %d out of range", index); return {}; } // Seek to TOC LUT item, read from LUT and get out data bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index); uint32_t tocEntryPos; serialization::readPod(bookFile, tocEntryPos); bookFile.seek(tocEntryPos); return readTocEntry(bookFile); } BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const { return readSpineEntryFrom(file); } BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const { return readTocEntryFrom(file); }