diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 1609ab3a..f29c894a 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -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(1u << styleIdx)); + return getAdvance(codepoint, styleIdx); +} + +uint8_t SdCardFont::styleIdxFromMissCtx(void* ctx) { return static_cast(ctx)->styleIdx; } + bool SdCardFont::hasAdvanceTable() const { for (uint8_t i = 0; i < MAX_STYLES; i++) { if (advanceTable_[i]) return true; diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 443fee68..4bd0c6a5 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -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; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a7e1a56a..e1e80d9c 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -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) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 04892f2e..98485c17 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -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; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index f90e85c0..4c0a13bf 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -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); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 7b21a72f..acabe9cc 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -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 diff --git a/src/activities/Activity.cpp b/src/activities/Activity.cpp index eaabc2aa..917d8d8c 100644 --- a/src/activities/Activity.cpp +++ b/src/activities/Activity.cpp @@ -1,10 +1,22 @@ #include "Activity.h" +#include + #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); } diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 31d5bf81..93bd0c7d 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -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) { diff --git a/src/activities/reader/BookPaginator.cpp b/src/activities/reader/BookPaginator.cpp index 2815e369..15d4448e 100644 --- a/src/activities/reader/BookPaginator.cpp +++ b/src/activities/reader/BookPaginator.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #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(kOpenScratchSize); - auto tempBookBuf = makeUniqueNoThrow(kBookArenaSize); - if (!scratchBuf || !tempBookBuf) { - LOG_ERR("FIB", "OOM: open working set (%u B, free heap %u)", - static_cast(kOpenScratchSize + kBookArenaSize), static_cast(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(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(kOpenScratchSize); + auto tempBookBuf = makeUniqueNoThrow(kBookArenaSize); + if (!scratchBuf || !tempBookBuf) { + LOG_ERR("FIB", "OOM: open working set (%u B, free heap %u)", + static_cast(kOpenScratchSize + kBookArenaSize), static_cast(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(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(bookUsed + 128); - if (!bookBuf_) { + auto scratchBuf = makeUniqueNoThrow(kOpenScratchSize); + if (!bookBuf_ || !scratchBuf) { LOG_ERR("FIB", "OOM: book arena reopen (%u B)", static_cast(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(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 parseBuf; + std::unique_ptr layoutBuf; + size_t layoutSize = 0; + size_t blockAfterParse = 0; + for (;;) { + parseBuf = makeUniqueNoThrow(parseSize); + if (!parseBuf && parseFlexible && parseSize > kParseFloor) { + parseSize = kParseFloor; + parseBuf = makeUniqueNoThrow(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(layoutSize); + break; } - if (largestBlock > 64 && largestBlock - 64 < scratchSize) { // allocator header slack - scratchSize = largestBlock - 64; - } - std::unique_ptr scratchBuf; - if (scratchSize >= kScratchFloor) { - scratchBuf = makeUniqueNoThrow(scratchSize); - } - if (!scratchBuf) { - LOG_ERR("FIB", "OOM: build scratch (want >= %u B, free heap %u, max block %u)", - static_cast(kScratchFloor), static_cast(freeHeap), static_cast(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(layoutSize), static_cast(parseSize), static_cast(freeHeap), + static_cast(largestBlock), static_cast(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(scratchSize), - static_cast(freeHeap), static_cast(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(st), - static_cast(scratch.highWater()), static_cast(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(st), static_cast(scratch.highWater()), static_cast(layoutSize), + static_cast(scratch.failedAllocSize()), static_cast(parseArena.highWater()), + static_cast(parseSize), static_cast(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(millis() - t0), static_cast(scratch.highWater()), - static_cast(scratchSize), static_cast(ESP.getFreeHeap())); + static_cast(layoutSize), static_cast(parseArena.highWater()), + static_cast(parseSize), static_cast(ESP.getFreeHeap())); } - scratchBuf.reset(); + layoutBuf.reset(); + parseBuf.reset(); if (!reallocChapterArenas()) return BookStatus::OutOfMemory; indexArena_.reset(); diff --git a/src/activities/reader/BookPaginator.h b/src/activities/reader/BookPaginator.h index 5926ff0f..c4994024 100644 --- a/src/activities/reader/BookPaginator.h +++ b/src/activities/reader/BookPaginator.h @@ -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(); diff --git a/src/activities/reader/CpFontAdapter.cpp b/src/activities/reader/CpFontAdapter.cpp index 75bb0ef8..c5292b42 100644 --- a/src/activities/reader/CpFontAdapter.cpp +++ b/src/activities/reader/CpFontAdapter.cpp @@ -1,7 +1,24 @@ #include "CpFontAdapter.h" +#include #include +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(fp4::toPixel(sd->ensureAdvance(sdStyle, codepoint))); + } const EpdGlyph* glyph = family->getGlyph(codepoint, style_); return glyph != nullptr ? static_cast(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) { diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d568072d..89580d13 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -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(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(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(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(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); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 687f8a2c..0bf43271 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -1,6 +1,4 @@ #pragma once -#include "FootnoteEntry.h" - #include #include #include @@ -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 cachedBookmarks; bool recentsEntryRemoved = false; unsigned long bookmarkMessageTime = 0UL; diff --git a/src/activities/reader/FreeInkPageRenderer.cpp b/src/activities/reader/FreeInkPageRenderer.cpp index 4ba954aa..ce4544b4 100644 --- a/src/activities/reader/FreeInkPageRenderer.cpp +++ b/src/activities/reader/FreeInkPageRenderer.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include +#include #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(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 scratchBuf; + if (scratchSize >= kImageScratchFloor) scratchBuf = makeUniqueNoThrow(scratchSize); if (!scratchBuf) { - LOG_ERR("FIBIMG", "OOM: image decode scratch (%u B)", static_cast(kImageScratchSize)); + LOG_ERR("FIBIMG", "OOM: image decode scratch (want %u B, max block %u)", static_cast(scratchSize), + static_cast(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; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 64f2af96..8ff1dfe1 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -1,6 +1,7 @@ #include "TxtReaderActivity.h" #include +#include #include #include #include @@ -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(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(status)); return false; diff --git a/src/util/BookCoverUtils.cpp b/src/util/BookCoverUtils.cpp index 5f175340..02b5ea16 100644 --- a/src/util/BookCoverUtils.cpp +++ b/src/util/BookCoverUtils.cpp @@ -7,8 +7,11 @@ #include #include #include +#include +#include #include +#include #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 bool withOpenBook(const std::string& epubPath, Fn&& fn) { - auto bookBuf = makeUniqueNoThrow(kBookArenaSize); auto scratchBuf = makeUniqueNoThrow(kScratchSize); + std::unique_ptr 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(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(bookSize), + static_cast(ESP.getFreeHeap()), + static_cast(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 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; } diff --git a/src/util/BookCoverUtils.h b/src/util/BookCoverUtils.h index c8a223f9..5b10c4e1 100644 --- a/src/util/BookCoverUtils.h +++ b/src/util/BookCoverUtils.h @@ -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); diff --git a/test/cpfont_adapter/CMakeLists.txt b/test/cpfont_adapter/CMakeLists.txt index efa01fff..e66007aa 100644 --- a/test/cpfont_adapter/CMakeLists.txt +++ b/test/cpfont_adapter/CMakeLists.txt @@ -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 diff --git a/test/cpfont_adapter/SdCardFontStubs.cpp b/test/cpfont_adapter/SdCardFontStubs.cpp new file mode 100644 index 00000000..5957eeea --- /dev/null +++ b/test/cpfont_adapter/SdCardFontStubs.cpp @@ -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 + +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; }