Add framebuffer release and font metadata access

Introduce methods to temporarily release the 48KB framebuffer during memory-intensive chapter builds, allowing the memory to be reused. Add font metadata query methods (hasGlyphMeta, ensureAdvance) that check glyph existence and fetch advance metrics without loading full bitmap data, avoiding expensive SD reads during layout measurement.
This commit is contained in:
Justin Mitchell
2026-07-08 04:46:25 -04:00
parent e027f60bf0
commit bfb8aacdae
19 changed files with 447 additions and 119 deletions
+75 -23
View File
@@ -7,8 +7,11 @@
#include <Logging.h>
#include <Memory.h>
#include <PngToBmpConverter.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <cstring>
#include <memory>
#include "activities/reader/EpubReaderUtils.h" // cacheDirForBook
#include "activities/reader/FreeInkBookStorage.h" // SdBookSource
@@ -23,19 +26,34 @@ using freeink::book::ManifestItem;
using freeink::book::ZipEntryReader;
// Container open is transient: metadata + ZIP catalog only, freed on return.
constexpr size_t kBookArenaSize = 48 * 1024;
constexpr size_t kScratchSize = 64 * 1024;
// Cover work runs at the home screen after its render has already carved up
// the heap, so the fixed-size scratch (measured container-parse high-water is
// ~47 KB) allocates first and the book arena flexes to whatever block
// remains — corpus book footprints are 5-40 KB, so a shrunken arena still
// opens almost everything, and a too-big book fails soft (retried next visit).
constexpr size_t kScratchSize = 52 * 1024;
constexpr size_t kBookArenaIdeal = 48 * 1024;
constexpr size_t kBookArenaFloor = 20 * 1024;
constexpr size_t kAllocSlack = 64; // TLSF block header/split overhead
// Opens the book file's container long enough to run `fn(book, source)`.
template <typename Fn>
bool withOpenBook(const std::string& epubPath, Fn&& fn) {
auto bookBuf = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kScratchSize);
std::unique_ptr<uint8_t[]> bookBuf;
size_t bookSize = 0;
if (scratchBuf) {
const size_t blockNow = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
bookSize = std::min(kBookArenaIdeal, blockNow > kAllocSlack ? blockNow - kAllocSlack : 0);
if (bookSize >= kBookArenaFloor) bookBuf = makeUniqueNoThrow<uint8_t[]>(bookSize);
}
if (!bookBuf || !scratchBuf) {
LOG_ERR("COVER", "OOM: book open arenas");
LOG_ERR("COVER", "OOM: book open arenas (book %u B, free heap %u, max block %u)", static_cast<unsigned>(bookSize),
static_cast<unsigned>(ESP.getFreeHeap()),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)));
return false;
}
Arena bookArena(bookBuf.get(), kBookArenaSize);
Arena bookArena(bookBuf.get(), bookSize);
Arena scratch(scratchBuf.get(), kScratchSize);
SdBookSource source;
@@ -115,32 +133,45 @@ bool extractItem(const Book& book, freeink::book::BookSource& source, Arena& scr
// Shared shape of both generators: extract the cover beside the output, run
// `convert(coverFile, bmpOut)`, clean up the temp, drop the output on failure.
// `coverlessOut` is true only when the container OPENED and provably has no
// usable cover — the one case callers may cache negatively; every other
// failure is potentially transient (OOM, SD hiccup) and worth retrying.
//
// The conversion runs AFTER withOpenBook returns: extraction needs the book
// arenas (~95 KB), the JPEG/PNG decoder needs ~53 KB of its own, and the
// heap cannot hold both at once — the temp file is the handoff between them.
template <typename ConvertFn>
bool generateFromCover(const std::string& epubPath, const std::string& outPath, ConvertFn&& convert,
bool* hadCoverOut) {
if (hadCoverOut != nullptr) *hadCoverOut = false;
bool* coverlessOut) {
if (coverlessOut != nullptr) *coverlessOut = false;
const std::string cacheDir = cacheDirForBook(epubPath);
Storage.ensureDirectoryExists(cacheDir.c_str());
bool converted = false;
bool hadCover = false;
bool coverless = false;
bool jpeg = false;
std::string tempPath;
const bool opened = withOpenBook(epubPath, [&](Book& book, freeink::book::BookSource& source, Arena& scratch) {
const ManifestItem* cover = findCoverItem(book);
if (cover == nullptr) {
LOG_DBG("COVER", "No cover image in manifest: %s", epubPath.c_str());
coverless = true;
return true; // opened fine, just coverless
}
const bool jpeg = isJpegItem(*cover);
jpeg = isJpegItem(*cover);
const bool png = !jpeg && isPngItem(*cover);
if (!jpeg && !png) {
LOG_ERR("COVER", "Unsupported cover format: %s", cover->href);
coverless = true; // format will not improve on retry
return true;
}
hadCover = true;
const std::string tempPath = cacheDir + (jpeg ? "/.cover.jpg" : "/.cover.png");
if (!extractItem(book, source, scratch, *cover, tempPath)) return true;
const std::string extractPath = cacheDir + (jpeg ? "/.cover.jpg" : "/.cover.png");
if (extractItem(book, source, scratch, *cover, extractPath)) tempPath = extractPath;
return true;
});
bool converted = false;
if (opened && !tempPath.empty()) {
{
HalFile coverFile;
HalFile bmpOut;
@@ -150,10 +181,9 @@ bool generateFromCover(const std::string& epubPath, const std::string& outPath,
// Both close at scope exit, before the temp file is removed below.
}
Storage.remove(tempPath.c_str());
return true;
});
}
if (hadCoverOut != nullptr) *hadCoverOut = hadCover;
if (coverlessOut != nullptr) *coverlessOut = opened && coverless;
if (!converted) Storage.remove(outPath.c_str());
return opened && converted;
}
@@ -186,25 +216,47 @@ bool generateCoverBmp(const std::string& epubPath, const bool cropped) {
nullptr);
}
bool thumbAttemptNeeded(const std::string& thumbPath) {
HalFile f;
if (!Storage.openFileForRead("COVER", thumbPath, f)) return true; // missing
const size_t size = f.fileSize();
// 0 = stale sentinel from a transient failure (legacy behavior wrote these
// for ANY failure): retry. 1 = decided coverless: leave it. >1 = real BMP.
return size == 0;
}
bool generateThumbBmp(const std::string& epubPath, const int height) {
const std::string outPath = thumbBmpPath(epubPath, height);
if (Storage.exists(outPath.c_str())) return true;
if (Storage.exists(outPath.c_str())) {
if (!thumbAttemptNeeded(outPath)) return true;
Storage.remove(outPath.c_str());
LOG_INF("COVER", "Retrying thumbnail with stale sentinel: %s", outPath.c_str());
}
const int targetWidth = height * 6 / 10; // Continue Reading card aspect (legacy)
bool hadCover = false;
bool coverless = false;
const bool ok = generateFromCover(
epubPath, outPath,
[targetWidth, height](const bool jpeg, HalFile& coverFile, HalFile& bmpOut) {
return jpeg ? JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverFile, bmpOut, targetWidth, height)
: PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverFile, bmpOut, targetWidth, height);
},
&hadCover);
&coverless);
if (ok) return true;
// Legacy behavior: an empty sentinel BMP stops re-generation attempts on
// every home screen visit for coverless/unsupported books.
HalFile sentinel;
Storage.openFileForWrite("COVER", outPath, sentinel);
// Sentinel ONLY when the container opened and provably lacks a usable
// cover — one byte, so it is distinguishable from the zero-byte leftovers
// of transient failures. Everything else leaves nothing behind and the
// next home screen visit retries.
if (coverless) {
HalFile sentinel;
if (Storage.openFileForWrite("COVER", outPath, sentinel)) {
const uint8_t marker = 'X';
sentinel.write(&marker, 1);
}
} else {
LOG_ERR("COVER", "Thumbnail generation failed (will retry): %s", epubPath.c_str());
}
return false;
}