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
+22
View File
@@ -1061,6 +1061,28 @@ void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sor
advanceTableSize_[styleIdx] = k;
}
bool SdCardFont::hasGlyphMeta(uint8_t styleIdx, uint32_t codepoint) const {
styleIdx &= (MAX_STYLES - 1);
const PerStyle& s = styles_[styleIdx];
if (!loaded_ || !s.present) return false;
return findGlobalGlyphIndex(s, codepoint) >= 0;
}
uint16_t SdCardFont::ensureAdvance(uint8_t styleIdx, uint32_t codepoint) {
styleIdx &= (MAX_STYLES - 1);
if (!loaded_ || !styles_[styleIdx].present) return 0;
const uint16_t cached = getAdvance(codepoint, styleIdx);
if (cached != 0) return cached;
// 0 is either "not in table" or a genuine zero-width glyph; only pay the
// SD read when the resident interval table says the glyph exists.
if (findGlobalGlyphIndex(styles_[styleIdx], codepoint) < 0) return 0;
uint32_t cp = codepoint;
fetchAdvancesForCodepoints(&cp, 1, static_cast<uint8_t>(1u << styleIdx));
return getAdvance(codepoint, styleIdx);
}
uint8_t SdCardFont::styleIdxFromMissCtx(void* ctx) { return static_cast<OverflowContext*>(ctx)->styleIdx; }
bool SdCardFont::hasAdvanceTable() const {
for (uint8_t i = 0; i < MAX_STYLES; i++) {
if (advanceTable_[i]) return true;
+17
View File
@@ -55,6 +55,23 @@ class SdCardFont {
// Returns the 12.4 fixed-point advance, or 0 if not found.
uint16_t getAdvance(uint32_t codepoint, uint8_t style) const;
// Layout-side metric access — the per-glyph path CpFontAdapter uses while
// the FreeInkBook engine measures a chapter. Neither touches bitmap data:
// going through getGlyph() would stream every occurrence's bitmap through
// the 8-slot overflow ring (one SD read per character — a crawling build).
//
// hasGlyphMeta answers existence from the resident interval table (no I/O).
bool hasGlyphMeta(uint8_t styleIdx, uint32_t codepoint) const;
// ensureAdvance returns the 12.4 fixed-point advanceX, fetching the glyph
// METADATA from SD once on a miss and merging it into the persistent
// advance table (so each unique codepoint costs one small read per
// session). Returns 0 when the style lacks the codepoint.
uint16_t ensureAdvance(uint8_t styleIdx, uint32_t codepoint);
// Recover the style index from an EpdFontData::glyphMissCtx (see
// fromMissCtx below for recovering the SdCardFont itself).
static uint8_t styleIdxFromMissCtx(void* ctx);
// Returns true if advance table is populated for at least one style.
bool hasAdvanceTable() const;
+14
View File
@@ -91,6 +91,20 @@ void GfxRenderer::begin() {
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
}
void GfxRenderer::releaseFrameBufferForBuild() {
display.releaseFrameBuffers();
frameBuffer = nullptr;
}
bool GfxRenderer::restoreFrameBufferAfterBuild() {
if (!display.reallocFrameBuffers()) {
LOG_ERR("GFX", "Framebuffer realloc failed after build");
return false;
}
frameBuffer = display.getFrameBuffer();
return frameBuffer != nullptr;
}
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
+9
View File
@@ -253,6 +253,15 @@ class GfxRenderer {
// Font helpers
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
// Lend the 48 KB framebuffer to a memory-hungry phase (chapter builds).
// Between release and a successful restore NOTHING may draw or display —
// the panel keeps showing its last refreshed image. restore returns the
// buffer white, so the caller must redraw the full screen; false means the
// heap could not re-supply the buffer (callers treat that as fatal).
void releaseFrameBufferForBuild();
bool restoreFrameBufferAfterBuild();
bool hasFrameBuffer() const { return frameBuffer != nullptr; }
// Low level functions
uint8_t* getFrameBuffer() const;
size_t getBufferSize() const;
+4
View File
@@ -77,6 +77,10 @@ void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
void HalDisplay::releaseFrameBuffers() { einkDisplay.releaseBuffers(); }
bool HalDisplay::reallocFrameBuffers() { return einkDisplay.reallocBuffers(); }
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
}
+6
View File
@@ -47,6 +47,12 @@ class HalDisplay {
// Access to frame buffer
uint8_t* getFrameBuffer() const;
// Lend the framebuffer's ~48 KB to a memory-hungry phase (chapter builds).
// No display calls between release and a successful realloc; the panel
// keeps its last refreshed image. Buffers come back white — redraw fully.
void releaseFrameBuffers();
bool reallocFrameBuffers();
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
// to the gray region in physical panel coordinates (no-arg = full frame).
// Call after the BW base frame is displayed and before the grayscale planes
+14 -2
View File
@@ -1,10 +1,22 @@
#include "Activity.h"
#include <esp_heap_caps.h>
#include "ActivityManager.h"
void Activity::onEnter() { LOG_DBG("ACT", "Entering activity: %s", name.c_str()); }
// Heap next to every transition: `max` (largest contiguous block) against
// `free` is the fragmentation picture — long-lived allocations made after
// boot-time churn pin the middle of the free region, and these lines bisect
// which step planted them.
void Activity::onEnter() {
LOG_DBG("ACT", "Entering activity: %s (heap free %u, max block %u)", name.c_str(), (unsigned)ESP.getFreeHeap(),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
}
void Activity::onExit() { LOG_DBG("ACT", "Exiting activity: %s", name.c_str()); }
void Activity::onExit() {
LOG_DBG("ACT", "Exiting activity: %s (heap free %u, max block %u)", name.c_str(), (unsigned)ESP.getFreeHeap(),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
}
void Activity::requestUpdate(bool immediate) { activityManager.requestUpdate(immediate); }
+4 -1
View File
@@ -60,7 +60,10 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
// Missing OR a stale zero-byte sentinel (transient-failure leftover)
// both warrant an attempt; a real thumb or a decided-coverless marker
// (1 byte) short-circuits without opening the container.
if (BookCoverUtils::thumbAttemptNeeded(coverPath)) {
// If epub, generate the Continue Reading thumbnail from its cover
if (FsHelpers::hasEpubExtension(book.path)) {
if (!showingLoading) {
+128 -73
View File
@@ -8,6 +8,7 @@
#include <esp_heap_caps.h>
#include <text/hyph_en_us.h>
#include <algorithm>
#include <cstring>
#include "CrossPointSettings.h"
@@ -76,38 +77,45 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
// exactly-sized buffer. The stylesheet is likewise compacted down to its
// real rule array. All open-time transients are freed before the
// per-chapter arenas are allocated.
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kOpenScratchSize);
auto tempBookBuf = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
if (!scratchBuf || !tempBookBuf) {
LOG_ERR("FIB", "OOM: open working set (%u B, free heap %u)",
static_cast<unsigned>(kOpenScratchSize + kBookArenaSize), static_cast<unsigned>(ESP.getFreeHeap()));
close();
return false;
}
Arena scratch(scratchBuf.get(), kOpenScratchSize);
bookArena_.init(tempBookBuf.get(), kBookArenaSize);
BookStatus st = book_.open(source_, bookArena_, scratch);
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Book open failed: %d (%s)", static_cast<int>(st), path.c_str());
close();
return false;
// PHASE A: parse into full-size temporaries to learn the real footprint,
// then free EVERYTHING. Persistents allocated into the emptied heap take
// the start of the big free region (or a pocket), leaving the tail
// contiguous — allocating them while transients are held, or keeping the
// temp arena, both proved to pin large blocks mid-heap and split the
// region the chapter builds need.
size_t bookUsed = 0;
{
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kOpenScratchSize);
auto tempBookBuf = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
if (!scratchBuf || !tempBookBuf) {
LOG_ERR("FIB", "OOM: open working set (%u B, free heap %u)",
static_cast<unsigned>(kOpenScratchSize + kBookArenaSize), static_cast<unsigned>(ESP.getFreeHeap()));
close();
return false;
}
Arena scratch(scratchBuf.get(), kOpenScratchSize);
Arena tempArena(tempBookBuf.get(), kBookArenaSize);
freeink::book::Book probeBook;
const BookStatus st = probeBook.open(source_, tempArena, scratch);
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Book open failed: %d (%s)", static_cast<int>(st), path.c_str());
close();
return false;
}
bookUsed = tempArena.used();
}
// Second pass into the exact footprint (+ slack for alignment). The
// temporary arena is freed FIRST — the reopen re-parses the container
// rather than copying, so peak in-flight stays at scratch + one arena.
const size_t bookUsed = bookArena_.used();
tempBookBuf.reset();
book_ = freeink::book::Book();
// PHASE B: exact-size arena into the emptied heap, re-parse for keeps.
bookBuf_ = makeUniqueNoThrow<uint8_t[]>(bookUsed + 128);
if (!bookBuf_) {
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kOpenScratchSize);
if (!bookBuf_ || !scratchBuf) {
LOG_ERR("FIB", "OOM: book arena reopen (%u B)", static_cast<unsigned>(bookUsed + 128));
close();
return false;
}
Arena scratch(scratchBuf.get(), kOpenScratchSize);
bookArena_.init(bookBuf_.get(), bookUsed + 128);
st = book_.open(source_, bookArena_, scratch);
const BookStatus st = book_.open(source_, bookArena_, scratch);
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Exact-size reopen failed: %d", static_cast<int>(st));
close();
@@ -159,12 +167,9 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
}
loadHyphenator();
// Per-chapter arenas last, once the open-time transients are gone.
if (!reallocChapterArenas()) {
close();
return false;
}
// Per-chapter arenas are allocated lazily by ensureChapter(): a first-open
// chapter build would only free them again, and every idle KB at build
// time is scratch budget.
open_ = true;
return true;
}
@@ -329,14 +334,21 @@ uint32_t BookPaginator::fontFingerprint() const {
uint32_t BookPaginator::generation() const { return freeink::book::layoutGenerationHash(params_, fontFingerprint()); }
bool BookPaginator::isChapterCached(const uint16_t spineIndex) {
char name[sizeof(cacheName_)];
if (!freeink::book::pageCacheName(spineIndex, generation(), name, sizeof(name))) return false;
return cache_.exists(name);
}
freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex, const BuildProgress& progress) {
const uint32_t gen = generation();
if (!freeink::book::pageCacheName(spineIndex, gen, cacheName_, sizeof(cacheName_))) {
return BookStatus::Unsupported;
}
indexArena_.reset();
curSpine_ = kNoSpine;
if (!indexBuf_ && !reallocChapterArenas()) return BookStatus::OutOfMemory;
indexArena_.reset();
BookStatus st = reader_.open(cache_, cacheName_, gen, indexArena_);
if (st == BookStatus::Ok) {
curSpine_ = spineIndex;
@@ -348,57 +360,95 @@ freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex
if (!isTxt_ && entry == nullptr) return BookStatus::NotFound;
if (isTxt_ && spineIndex != 0) return BookStatus::NotFound;
// The pagination working set (layout buffers + one inflate stream + the
// writer's page index) lives only for this call — but it needs one big
// CONTIGUOUS block, and everything else the parse touches does NOT come
// from this arena: expat's internal pools allocate from the system heap.
// Grabbing the largest possible block starved exactly that — the heap
// dipped to ~6 KB mid-parse and a failed expat malloc surfaces as a bogus
// ParseError. So size adaptively: as much as available AFTER a reserve for
// the parser's heap use, capped at the ideal, floored at what an ordinary
// text chapter needs. Free the idle per-chapter arenas first so their
// space can coalesce into the block.
// The pagination working set lives only for this call, as TWO arenas:
// layout buffers + the writer's index in one, the parse stream (inflate
// window + decompressor + XML chunks) in the other. Splitting kills the
// single-~100 KB-contiguous-block requirement that fragmentation kept
// defeating — two ~50 KB blocks fit every heap shape observed so far
// (including a 59 KB max block). Expat's pools still come from the system
// heap, so a reserve stays out of both. Free the idle per-chapter arenas
// first so their space is available.
reader_ = freeink::book::PageCacheReader();
indexBuf_.reset();
pageBuf_.reset();
// Expat pools + misc system-heap use during the parse. Measured on device:
// a 28 KB reserve bottomed out at ~8-10 KB free mid-build, so ~19 KB is
// real parser use; 36 KB keeps a comfortable floor without starving the
// scratch (typical chapters use ~76 KB of it).
constexpr size_t kParserHeapReserve = 36 * 1024;
constexpr size_t kScratchFloor = 88 * 1024; // text-chapter working set + writer index
// Measured (small profile, host + device): layout side high water ~45.3 KB
// INCLUDING the writer's chunked index (~2.5 KB for a normal chapter);
// parse side ~46 KB for a DEFLATED entry (inflate window + decompressor
// are irreducible), ~4 KB for a STORED one. Expat system-heap use ~19 KB.
// If ParseError appears on a book that fibchecks clean on host, the
// reserve is being squeezed — raise it first.
constexpr size_t kParserHeapReserve = 22 * 1024;
constexpr size_t kLayoutIdeal = 60 * 1024;
// Floor is measured-demand-plus-margin, not comfort: heaviest observed
// chapter (device, incl. chunked writer index) is 44,964 B. Raising this
// makes builds fail upfront on heap shapes where they would have fit —
// a 46 KiB floor already rejected a 46,452 B offer that would have built.
constexpr size_t kLayoutFloor = 45 * 1024;
constexpr size_t kParseIdeal = 50 * 1024;
constexpr size_t kParseFloor = 46 * 1024;
constexpr size_t kParseStored = 8 * 1024; // stored entries skip inflate state
constexpr size_t kAllocSlack = 64; // TLSF block header/split overhead
const bool stored = !isTxt_ && entry->method == 0;
const size_t freeHeap = ESP.getFreeHeap();
const size_t largestBlock = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
size_t scratchSize = kBuildScratchSize;
if (freeHeap > kParserHeapReserve && freeHeap - kParserHeapReserve < scratchSize) {
scratchSize = freeHeap - kParserHeapReserve;
const size_t budget = freeHeap > kParserHeapReserve ? freeHeap - kParserHeapReserve : 0;
// The parse arena goes first: its size is rigid (inflate state), and
// placing it lets TLSF spend a mid-sized pocket on it when one exists. The
// layout arena then takes what the heap can actually give: both draws
// usually carve out of the same largest block, so sizing from the block
// that REMAINS after the parse arena landed is what makes the pair fit —
// free-heap math alone kept promising space TLSF couldn't deliver. When
// the leftover misses the layout floor and the parse arena is still above
// ITS floor, shrink parse and re-measure (there is ~2 KB of allocator
// overhead between the two draws no upfront formula gets right).
const bool parseFlexible = !stored && !isTxt_;
size_t parseSize = parseFlexible ? kParseIdeal : kParseStored;
if (parseFlexible && parseSize + kLayoutFloor > budget) parseSize = kParseFloor;
std::unique_ptr<uint8_t[]> parseBuf;
std::unique_ptr<uint8_t[]> layoutBuf;
size_t layoutSize = 0;
size_t blockAfterParse = 0;
for (;;) {
parseBuf = makeUniqueNoThrow<uint8_t[]>(parseSize);
if (!parseBuf && parseFlexible && parseSize > kParseFloor) {
parseSize = kParseFloor;
parseBuf = makeUniqueNoThrow<uint8_t[]>(parseSize);
}
if (!parseBuf) break;
blockAfterParse = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
const size_t freeNow = ESP.getFreeHeap();
layoutSize = std::min(kLayoutIdeal, blockAfterParse > kAllocSlack ? blockAfterParse - kAllocSlack : 0);
layoutSize = std::min(layoutSize, freeNow > kParserHeapReserve ? freeNow - kParserHeapReserve : 0);
if (layoutSize < kLayoutFloor && parseFlexible && parseSize > kParseFloor) {
parseBuf.reset(); // give the block back, retry with the smaller parse arena
parseSize = kParseFloor;
continue;
}
if (layoutSize >= kLayoutFloor) layoutBuf = makeUniqueNoThrow<uint8_t[]>(layoutSize);
break;
}
if (largestBlock > 64 && largestBlock - 64 < scratchSize) { // allocator header slack
scratchSize = largestBlock - 64;
}
std::unique_ptr<uint8_t[]> scratchBuf;
if (scratchSize >= kScratchFloor) {
scratchBuf = makeUniqueNoThrow<uint8_t[]>(scratchSize);
}
if (!scratchBuf) {
LOG_ERR("FIB", "OOM: build scratch (want >= %u B, free heap %u, max block %u)",
static_cast<unsigned>(kScratchFloor), static_cast<unsigned>(freeHeap), static_cast<unsigned>(largestBlock));
if (!layoutBuf || !parseBuf) {
LOG_ERR("FIB", "OOM: build arenas (want %u+%u B, free heap %u, max block %u, after parse %u)",
static_cast<unsigned>(layoutSize), static_cast<unsigned>(parseSize), static_cast<unsigned>(freeHeap),
static_cast<unsigned>(largestBlock), static_cast<unsigned>(blockAfterParse));
layoutBuf.reset();
parseBuf.reset();
reallocChapterArenas(); // restore the per-chapter arenas for the caller
return BookStatus::OutOfMemory;
}
if (scratchSize != kBuildScratchSize) {
LOG_INF("FIB", "Build scratch sized to %u B (free heap %u, max block %u)", static_cast<unsigned>(scratchSize),
static_cast<unsigned>(freeHeap), static_cast<unsigned>(largestBlock));
}
Arena scratch(scratchBuf.get(), scratchSize);
Arena scratch(layoutBuf.get(), layoutSize);
Arena parseArena(parseBuf.get(), parseSize);
const uint32_t t0 = millis();
{
PageCacheWriter writer;
if (!writer.begin(cache_, cacheName_, gen, scratch)) {
scratchBuf.reset();
layoutBuf.reset();
parseBuf.reset();
reallocChapterArenas();
return BookStatus::IoError;
}
@@ -407,21 +457,26 @@ freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex
uint32_t totalChars = 0;
st = isTxt_ ? ChapterLayout::layoutPlainText(source_, params_, scratch, sink, nullptr, &totalChars)
: ChapterLayout::layout(source_, book_.zip(), *entry, item->href, params_, scratch, sink, nullptr,
&totalChars);
&totalChars, &parseArena);
writer.setTotalChars(totalChars);
if (st == BookStatus::Ok && !writer.finish()) st = BookStatus::IoError;
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Chapter %u layout failed: %d (scratch high water %u/%u B)", spineIndex, static_cast<int>(st),
static_cast<unsigned>(scratch.highWater()), static_cast<unsigned>(scratchSize));
scratchBuf.reset();
LOG_ERR("FIB", "Chapter %u layout failed: %d (layout %u/%u refused %u, parse %u/%u refused %u B)", spineIndex,
static_cast<int>(st), static_cast<unsigned>(scratch.highWater()), static_cast<unsigned>(layoutSize),
static_cast<unsigned>(scratch.failedAllocSize()), static_cast<unsigned>(parseArena.highWater()),
static_cast<unsigned>(parseSize), static_cast<unsigned>(parseArena.failedAllocSize()));
layoutBuf.reset();
parseBuf.reset();
reallocChapterArenas();
return st;
}
LOG_INF("FIB", "Chapter %u paginated: %u pages in %ums (scratch high water %u/%u B, free heap %u)", spineIndex,
LOG_INF("FIB", "Chapter %u paginated: %u pages in %ums (layout %u/%u, parse %u/%u B, free heap %u)", spineIndex,
writer.pageCount(), static_cast<unsigned>(millis() - t0), static_cast<unsigned>(scratch.highWater()),
static_cast<unsigned>(scratchSize), static_cast<unsigned>(ESP.getFreeHeap()));
static_cast<unsigned>(layoutSize), static_cast<unsigned>(parseArena.highWater()),
static_cast<unsigned>(parseSize), static_cast<unsigned>(ESP.getFreeHeap()));
}
scratchBuf.reset();
layoutBuf.reset();
parseBuf.reset();
if (!reallocChapterArenas()) return BookStatus::OutOfMemory;
indexArena_.reset();
+6 -3
View File
@@ -93,6 +93,11 @@ class BookPaginator {
// Everything layout-relevant, hashed — the cache key.
uint32_t generation() const;
// True when the chapter already has a page cache for the current
// generation — i.e. ensureChapter() will be a fast open, not a build.
// Callers use this to show the indexing popup before the heavy path.
bool isChapterCached(uint16_t spineIndex);
// Opens the chapter's page cache for the current generation, laying the
// chapter out first when missing or stale. Heavy only on that miss.
freeink::book::BookStatus ensureChapter(uint16_t spineIndex, const BuildProgress& progress = BuildProgress());
@@ -131,10 +136,8 @@ class BookPaginator {
// high-water). Deliberately snug: open needs scratch + the 48 KB temp book
// arena simultaneously, and the C3's largest free block hovers just above
// 112 KB — 64 KB here made the pair miss fitting by a few dozen bytes.
// (Chapter builds size their two arenas adaptively in ensureChapter.)
static constexpr size_t kOpenScratchSize = 56 * 1024;
// Chapter pagination: layout buffers + inflate + writer index. The ideal
// size; ensureChapter steps down when fragmentation denies a block this big.
static constexpr size_t kBuildScratchSize = 120 * 1024;
bool buildFontChain(GfxRenderer& renderer);
bool reallocChapterArenas();
+37 -4
View File
@@ -1,7 +1,24 @@
#include "CpFontAdapter.h"
#include <SdCardFont.h>
#include <Utf8.h>
namespace {
// SD-backed families must answer layout metrics from resident metadata + the
// persistent advance table. The generic getGlyph() path loads the FULL BITMAP
// through an 8-slot overflow ring on every miss — during a chapter build that
// is one SD read per character occurrence and the build crawls (observed as
// an endless "SDCF Overflow: loaded U+..." stream while paginating).
SdCardFont* sdFontFor(const EpdFontFamily* family, const EpdFontFamily::Style style, uint8_t* styleIdxOut) {
const EpdFontData* data = family->getData(style);
if (data == nullptr || data->glyphMissCtx == nullptr) return nullptr;
*styleIdxOut = SdCardFont::styleIdxFromMissCtx(data->glyphMissCtx);
return SdCardFont::fromMissCtx(data->glyphMissCtx);
}
} // namespace
bool CpFontAdapter::addSize(const uint16_t sizePx, const EpdFontFamily* family) {
if (family == nullptr || count_ >= kMaxLadder) return false;
if (count_ > 0 && sizePx <= ladder_[count_ - 1].sizePx) return false; // ascending only
@@ -35,6 +52,10 @@ int16_t CpFontAdapter::advance(const uint32_t codepoint, const uint16_t sizePx,
if (utf8IsCombiningMark(codepoint)) return 0;
const EpdFontFamily* family = familyFor(sizePx);
if (family == nullptr) return 0;
uint8_t sdStyle = 0;
if (SdCardFont* sd = sdFontFor(family, style_, &sdStyle)) {
return static_cast<int16_t>(fp4::toPixel(sd->ensureAdvance(sdStyle, codepoint)));
}
const EpdGlyph* glyph = family->getGlyph(codepoint, style_);
return glyph != nullptr ? static_cast<int16_t>(fp4::toPixel(glyph->advanceX)) : 0;
}
@@ -55,9 +76,16 @@ int16_t CpFontAdapter::kerning(const uint32_t left, const uint32_t right, const
if (utf8IsCombiningMark(left) || utf8IsCombiningMark(right)) return 0;
const EpdFontFamily* family = familyFor(sizePx);
if (family == nullptr) return 0;
const EpdGlyph* leftGlyph = family->getGlyph(left, style_);
if (leftGlyph == nullptr) return 0;
const int32_t advFP = leftGlyph->advanceX; // 12.4 fixed-point
int32_t advFP = 0; // 12.4 fixed-point
uint8_t sdStyle = 0;
if (SdCardFont* sd = sdFontFor(family, style_, &sdStyle)) {
advFP = sd->ensureAdvance(sdStyle, left);
if (advFP == 0) return 0;
} else {
const EpdGlyph* leftGlyph = family->getGlyph(left, style_);
if (leftGlyph == nullptr) return 0;
advFP = leftGlyph->advanceX;
}
const int32_t kernFP = family->getKerning(left, right, style_); // 4.4 fixed-point
// Differential-rounding parity: layout adds advance(left) + kerning(left,
// right); returning the delta between the renderer's fused snap and the
@@ -73,7 +101,12 @@ uint32_t CpFontAdapter::ligature(const uint32_t left, const uint32_t right, uint
bool CpFontAdapter::hasGlyph(const uint32_t codepoint) const {
if (count_ == 0) return false;
return ladder_[count_ - 1].family->hasGlyph(codepoint, style_);
const EpdFontFamily* family = ladder_[count_ - 1].family;
uint8_t sdStyle = 0;
if (SdCardFont* sd = sdFontFor(family, style_, &sdStyle)) {
return sd->hasGlyphMeta(sdStyle, codepoint);
}
return family->hasGlyph(codepoint, style_);
}
const freeink::book::GlyphBitmap* CpFontAdapter::rasterize(uint32_t, uint16_t) {
+44 -2
View File
@@ -2,6 +2,7 @@
#include <BookXPath.h>
#include <FontCacheManager.h>
#include <FontDecompressor.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -132,6 +133,11 @@ void EpubReaderActivity::onEnter() {
cacheDir_ = cacheDirForBook(path_);
Storage.ensureDirectoryExists("/.crosspoint");
// Container open takes ~2 s (two parse passes on the C3); without feedback
// the stale previous screen just freezes.
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
pagesUntilFullRefresh = 1; // HALF-clear the popup under the first page
if (!paginator.open(path_, cacheDir_, renderer)) {
LOG_ERR("ERS", "Failed to open book: %s", path_.c_str());
activityManager.goToFullScreenMessage(tr(STR_PAGE_LOAD_ERROR), EpdFontFamily::BOLD);
@@ -157,7 +163,9 @@ void EpubReaderActivity::onEnter() {
}
RECENT_BOOKS.addBook(path_, title, paginator.author(), cacheDir_ + "/thumb_[HEIGHT].bmp");
loadCachedBookmarks();
// Bookmarks load lazily after the first page renders (see render()): the
// JSON text + parsed vector would otherwise sit on the heap through the
// first chapter build, which is the tightest allocation window we have.
requestUpdate();
}
@@ -648,11 +656,34 @@ bool EpubReaderActivity::ensureChapterAndPosition() {
chapterOpen = false;
buildPopupShown = false;
// A missing cache means a multi-second pagination: show the popup BEFORE
// the build so the screen never sits frozen. The progress callback stays
// as the fallback for stale-cache rebuilds detected inside the engine.
if (!paginator.isChapterCached(static_cast<uint16_t>(currentSpineIndex))) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
pagesUntilFullRefresh = 1; // HALF-clear the popup under the first page
buildPopupShown = true;
// Deflated chapters need every heap byte for the build scratch; the
// glyph caches re-warm on the next page's prewarm pass anyway. Do NOT
// release the SD font tables here: layout calls advance() per glyph
// and without the resident mini data every lookup becomes an SD read
// through the 8-slot overflow cache (observed as a crawling build).
if (auto* fcm = renderer.getFontCacheManager()) {
if (auto* fdc = fcm->getDecompressor()) fdc->clearCache();
}
// Lend the framebuffer's 48 KB to the build arenas: the popup just
// displayed stays on the panel (e-ink is persistent), and render()
// fully redraws once the buffer is restored below.
renderer.releaseFrameBufferForBuild();
}
BookPaginator::BuildProgress progressCb;
progressCb.ctx = this;
progressCb.fn = [](void* ctx, uint32_t) {
auto* self = static_cast<EpubReaderActivity*>(ctx);
if (!self->buildPopupShown) {
// No framebuffer while it is lent to the build — skip popup drawing
// (the pre-build popup is already on the panel in that case).
if (!self->buildPopupShown && self->renderer.hasFrameBuffer()) {
GUI.drawPopup(self->renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else it ghosts.
self->pagesUntilFullRefresh = 1;
@@ -661,6 +692,13 @@ bool EpubReaderActivity::ensureChapterAndPosition() {
};
const auto status = paginator.ensureChapter(static_cast<uint16_t>(currentSpineIndex), progressCb);
if (!renderer.hasFrameBuffer() && !renderer.restoreFrameBufferAfterBuild()) {
// Unrecoverable: nothing can be drawn again. The build arenas are freed
// (ensureChapter released them), so this realloc failing means the heap
// is corrupt — restart rather than run blind.
LOG_ERR("ERS", "Framebuffer restore failed - restarting");
ESP.restart();
}
if (status != freeink::book::BookStatus::Ok) {
LOG_ERR("ERS", "ensureChapter(%d) failed: %d", currentSpineIndex, static_cast<int>(status));
return false;
@@ -795,6 +833,9 @@ void EpubReaderActivity::render(RenderLock&& lock) {
lastCharStart = page.charStart;
currentPageFootnotes = FreeInkPageRenderer::collectFootnotes(page);
if (!bookmarksLoaded) {
loadCachedBookmarks(); // deferred from onEnter — past the build's heap peak
}
updateBookmarkFlag();
const auto start = millis();
@@ -1003,6 +1044,7 @@ void EpubReaderActivity::restoreSavedPosition() {
}
void EpubReaderActivity::loadCachedBookmarks() {
bookmarksLoaded = true;
cachedBookmarks.clear();
if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) {
cachedBookmarks.reserve(initialBookmarkCacheCapacity);
+2 -2
View File
@@ -1,6 +1,4 @@
#pragma once
#include "FootnoteEntry.h"
#include <optional>
#include <string>
#include <vector>
@@ -9,6 +7,7 @@
#include "BookmarkEntry.h"
#include "EndOfBookOptions.h"
#include "EpubReaderMenuActivity.h"
#include "FootnoteEntry.h"
#include "activities/Activity.h"
// EPUB reading UI over the FreeInkBook engine (BookPaginator). Position is
@@ -48,6 +47,7 @@ class EpubReaderActivity final : public Activity {
bool currentPageBookmarked = false;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
bool buildPopupShown = false; // indexing popup drawn for the current build
bool bookmarksLoaded = false; // deferred until after the first page render
std::vector<BookmarkEntry> cachedBookmarks;
bool recentsEntryRemoved = false;
unsigned long bookmarkMessageTime = 0UL;
+24 -5
View File
@@ -5,10 +5,12 @@
#include <HalStorage.h>
#include <Logging.h>
#include <Memory.h>
#include <esp_heap_caps.h>
#include <render/ImageRenderer.h>
#include <algorithm>
#include <cstdio>
#include <memory>
#include "BookPaginator.h"
#include "CrossPointSettings.h"
@@ -20,7 +22,13 @@ using freeink::book::PageTextRun;
namespace {
// Decode scratch for one image: inflate window + PNG/JPEG decoder state.
constexpr size_t kImageScratchSize = 72 * 1024;
// Ideal covers a deflated PNG (46 KB inflate + decoder + row buffers); the
// size flexes down to what the largest free block can give — a stored JPEG
// needs far less, and ImageRenderer fails soft (OutOfMemory) if the arena
// really is too small for the specific image.
constexpr size_t kImageScratchIdeal = 72 * 1024;
constexpr size_t kImageScratchFloor = 32 * 1024;
constexpr size_t kImageAllocSlack = 64;
// 4x4 Bayer matrix (0..15) for quantizing the two mid-gray levels in BW mode.
constexpr uint8_t kBayer4[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
@@ -72,12 +80,16 @@ bool ensureImageCached(BookPaginator& paginator, const std::string& cacheDir, co
if (Storage.exists(path)) return true;
(void)cacheDir;
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kImageScratchSize);
const size_t block = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
const size_t scratchSize = std::min(kImageScratchIdeal, block > kImageAllocSlack ? block - kImageAllocSlack : 0);
std::unique_ptr<uint8_t[]> scratchBuf;
if (scratchSize >= kImageScratchFloor) scratchBuf = makeUniqueNoThrow<uint8_t[]>(scratchSize);
if (!scratchBuf) {
LOG_ERR("FIBIMG", "OOM: image decode scratch (%u B)", static_cast<unsigned>(kImageScratchSize));
LOG_ERR("FIBIMG", "OOM: image decode scratch (want %u B, max block %u)", static_cast<unsigned>(scratchSize),
static_cast<unsigned>(block));
return false;
}
freeink::book::Arena scratch(scratchBuf.get(), kImageScratchSize);
freeink::book::Arena scratch(scratchBuf.get(), scratchSize);
char tmpPath[192];
snprintf(tmpPath, sizeof(tmpPath), "%s.tmp", path);
@@ -119,8 +131,15 @@ void drawImageFromCache(const GfxRenderer& renderer, const char* path, const Pag
if (rowBytes > sizeof(rowBuf)) return;
for (uint16_t y = 0; y < h; ++y) {
if (f.read(rowBuf, rowBytes) != rowBytes) return;
const int screenY = img.y + y;
// Tiled-grayscale band culling: rows outside the active strip are seeked
// past, not read — without this every strip pass re-reads and re-iterates
// the whole image (12+ full passes for a cover page, seconds of work).
if (!renderer.glyphIntersectsStrip(img.x, screenY, img.x + w, screenY + 1)) {
if (!f.seekCur(rowBytes)) return;
continue;
}
if (f.read(rowBuf, rowBytes) != rowBytes) return;
for (uint16_t x = 0; x < w; ++x) {
const uint8_t level = (rowBuf[x >> 2] >> ((3 - (x & 3)) * 2)) & 0x3; // 0=black..3=white
const int screenX = img.x + x;
+20 -1
View File
@@ -1,6 +1,7 @@
#include "TxtReaderActivity.h"
#include <FontCacheManager.h>
#include <FontDecompressor.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -108,11 +109,25 @@ bool TxtReaderActivity::ensureChapterAndPosition() {
chapterOpen = false;
buildPopupShown = false;
if (!paginator.isChapterCached(0)) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
pagesUntilFullRefresh = 1;
buildPopupShown = true;
// Free the glyph caches for the build; they re-warm on the next render.
// (SD font tables stay resident — layout needs them per glyph.)
if (auto* fcm = renderer.getFontCacheManager()) {
if (auto* fdc = fcm->getDecompressor()) fdc->clearCache();
}
// Lend the framebuffer's 48 KB to the build arenas; the popup stays on
// the panel and render() fully redraws after the restore below.
renderer.releaseFrameBufferForBuild();
}
BookPaginator::BuildProgress progressCb;
progressCb.ctx = this;
progressCb.fn = [](void* ctx, uint32_t) {
auto* self = static_cast<TxtReaderActivity*>(ctx);
if (!self->buildPopupShown) {
if (!self->buildPopupShown && self->renderer.hasFrameBuffer()) {
GUI.drawPopup(self->renderer, tr(STR_INDEXING));
self->pagesUntilFullRefresh = 1;
self->buildPopupShown = true;
@@ -120,6 +135,10 @@ bool TxtReaderActivity::ensureChapterAndPosition() {
};
const auto status = paginator.ensureChapter(0, progressCb);
if (!renderer.hasFrameBuffer() && !renderer.restoreFrameBufferAfterBuild()) {
LOG_ERR("TRS", "Framebuffer restore failed - restarting");
ESP.restart();
}
if (status != freeink::book::BookStatus::Ok) {
LOG_ERR("TRS", "Pagination failed: %d", static_cast<int>(status));
return false;
+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;
}
+10 -3
View File
@@ -22,11 +22,18 @@ std::string thumbBmpPathTemplate(const std::string& epubPath);
// Screen-sized cover for the sleep screen. No-op when the file exists.
bool generateCoverBmp(const std::string& epubPath, bool cropped);
// 1-bit thumbnail for the home screen (fast BW blit). No-op when the file
// exists. On failure or missing cover an empty sentinel file is written so
// the generation is not retried every visit (legacy behavior).
// 1-bit thumbnail for the home screen (fast BW blit). No-op when a real
// thumbnail exists. A provably coverless book writes a 1-byte sentinel so it
// is not re-probed every visit; a legacy/stale ZERO-byte sentinel (left by a
// transient failure) is healed by retrying.
bool generateThumbBmp(const std::string& epubPath, int height);
// True when a generation attempt is warranted for the resolved thumb path:
// the file is missing OR is a stale zero-byte sentinel. A 1-byte coverless
// sentinel and any real BMP both return false. Callers gate the loading
// popup (and the per-book container open) on this.
bool thumbAttemptNeeded(const std::string& thumbPath);
// Book title/author straight from the OPF (for recents entries).
bool readMetadata(const std::string& epubPath, std::string* titleOut, std::string* authorOut);
+1
View File
@@ -12,6 +12,7 @@ if(NOT EXISTS "${FREEINK_BOOK_INCLUDE}")
else()
add_executable(CpFontAdapterTest
CpFontAdapterTest.cpp
SdCardFontStubs.cpp
${REPO_ROOT}/src/activities/reader/CpFontAdapter.cpp
${REPO_ROOT}/lib/EpdFont/EpdFont.cpp
${REPO_ROOT}/lib/EpdFont/EpdFontFamily.cpp
+10
View File
@@ -0,0 +1,10 @@
// Link stubs for the adapter's SD-backed metric path. The suite's test
// families have no glyphMissCtx, so CpFontAdapter never calls into SdCardFont
// here — but the symbols must resolve, and SdCardFont.cpp itself needs
// Arduino/SD and cannot build on host.
#include <SdCardFont.h>
bool SdCardFont::hasGlyphMeta(uint8_t, uint32_t) const { return false; }
uint16_t SdCardFont::ensureAdvance(uint8_t, uint32_t) { return 0; }
uint8_t SdCardFont::styleIdxFromMissCtx(void*) { return 0; }
SdCardFont* SdCardFont::fromMissCtx(void*) { return nullptr; }