Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6174cbc613 | ||
|
|
9d147cade9 | ||
|
|
792aab6d97 | ||
|
|
b58b51bfbe | ||
|
|
98118d6e24 | ||
|
|
3cc4069faa | ||
|
|
a3b8897977 | ||
|
|
f7f1a6f8bf | ||
|
|
7cad3cfb0e | ||
|
|
e66de575a1 | ||
|
|
bae29fc6ba | ||
|
|
379f49d165 |
@@ -25,3 +25,10 @@ lib/EpdFont/scripts/output/
|
||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
/managed_components
|
||||
/.dummy
|
||||
/CMakeLists.txt
|
||||
/dependencies.lock
|
||||
/sdkconfig.default
|
||||
/sdkconfig.defaults
|
||||
sdkconfig.sticky
|
||||
|
||||
+44
-17
@@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||
const char* asCStr(const char* s) { return s; }
|
||||
|
||||
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||
// next page's need), eroding the largest contiguous block all session. With
|
||||
// reuse, capacities converge on the book's max page after a few turns and page
|
||||
// turns stop touching the allocator. Only three small instantiations exist
|
||||
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||
template <typename T, typename CapT>
|
||||
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||
if (buf && capacity >= needed) return true;
|
||||
delete[] buf;
|
||||
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SdCardFont::~SdCardFont() { freeAll(); }
|
||||
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
||||
s.miniBitmap = nullptr;
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniIntervalCapacity = 0;
|
||||
s.miniGlyphCapacity = 0;
|
||||
s.miniBitmapCapacity = 0;
|
||||
freeStyleMiniKern(s);
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
s.miniKernLeftCapacity = 0;
|
||||
s.miniKernRightCapacity = 0;
|
||||
s.miniKernMatrixCapacity = 0;
|
||||
}
|
||||
|
||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
@@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||
}
|
||||
|
||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
||||
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||
// non-coalescing holes in the heap every page turn).
|
||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
||||
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||
matrixBytes);
|
||||
freeStyleMiniKern(s);
|
||||
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
return missed;
|
||||
}
|
||||
|
||||
// Build mini intervals from sorted codepoints
|
||||
freeStyleMiniData(s);
|
||||
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniKernLeftEntryCount = 0;
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
|
||||
uint32_t intervalCapacity = validCount;
|
||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
||||
if (!s.miniIntervals) {
|
||||
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
return static_cast<int>(cpCount);
|
||||
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate mini glyph array
|
||||
s.miniGlyphCount = validCount;
|
||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
||||
if (!s.miniGlyphs) {
|
||||
// Mini glyph array (reused across pages when it fits)
|
||||
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
freeStyleMiniData(s);
|
||||
return static_cast<int>(cpCount);
|
||||
}
|
||||
s.miniGlyphCount = validCount;
|
||||
|
||||
// Build sorted read order for sequential I/O
|
||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||
@@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||
}
|
||||
|
||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
||||
if (!s.miniBitmap) {
|
||||
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||
delete[] readOrder;
|
||||
delete[] mappings;
|
||||
|
||||
@@ -168,13 +168,22 @@ class SdCardFont {
|
||||
// Stub EpdFontData returned when not prewarmed
|
||||
EpdFontData stubData{};
|
||||
|
||||
// Mini EpdFontData built during prewarm
|
||||
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||
// After a few pages the capacities converge on the book's max and page turns
|
||||
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||
// zeroes capacities) for style eviction / font unload.
|
||||
EpdFontData miniData{};
|
||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||
EpdGlyph* miniGlyphs = nullptr;
|
||||
uint8_t* miniBitmap = nullptr;
|
||||
uint32_t miniIntervalCount = 0;
|
||||
uint32_t miniGlyphCount = 0;
|
||||
uint32_t miniIntervalCapacity = 0;
|
||||
uint32_t miniGlyphCapacity = 0;
|
||||
uint32_t miniBitmapCapacity = 0;
|
||||
|
||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||
@@ -189,6 +198,10 @@ class SdCardFont {
|
||||
uint8_t miniKernLeftClassCount = 0;
|
||||
uint8_t miniKernRightClassCount = 0;
|
||||
int8_t* miniKernMatrix = nullptr;
|
||||
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||
uint16_t miniKernLeftCapacity = 0;
|
||||
uint16_t miniKernRightCapacity = 0;
|
||||
uint32_t miniKernMatrixCapacity = 0;
|
||||
|
||||
// The EpdFont whose data pointer we manage
|
||||
EpdFont epdFont{&stubData};
|
||||
|
||||
+56
-4
@@ -256,8 +256,44 @@ void Epub::parseCssFiles() const {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some converters emit one byte-identical stylesheet per chapter (100+ .css
|
||||
// entries), and each parse costs a zip locate plus an SD extract round-trip.
|
||||
// Map every CSS path to its central-directory (CRC32, compressed size) in a
|
||||
// single scan and parse only the first of each identical pair. Rules merge
|
||||
// into one global set, so dropping exact duplicates cannot lose styles. A
|
||||
// path that never matches a directory entry keeps key 0 and always parses.
|
||||
std::vector<uint64_t> dedupKeys(cssFiles.size(), 0);
|
||||
if (cssFiles.size() > 1) {
|
||||
std::unordered_map<std::string, size_t> pathToIndex;
|
||||
pathToIndex.reserve(cssFiles.size());
|
||||
for (size_t i = 0; i < cssFiles.size(); i++) {
|
||||
pathToIndex.emplace(FsHelpers::normalisePath(cssFiles[i]), i);
|
||||
}
|
||||
ZipFile(filepath).enumerateFileEntries([&](std::string_view entryPath, uint32_t crc32, uint32_t compressedSize) {
|
||||
if (!FsHelpers::hasCssExtension(entryPath)) {
|
||||
return;
|
||||
}
|
||||
const auto it = pathToIndex.find(std::string{entryPath});
|
||||
if (it != pathToIndex.end()) {
|
||||
dedupKeys[it->second] = (static_cast<uint64_t>(crc32) << 32) | compressedSize;
|
||||
}
|
||||
});
|
||||
}
|
||||
std::vector<uint64_t> seenKeys;
|
||||
seenKeys.reserve(cssFiles.size());
|
||||
size_t skippedDuplicates = 0;
|
||||
|
||||
// No cache yet - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
for (size_t cssIndex = 0; cssIndex < cssFiles.size(); cssIndex++) {
|
||||
const auto& cssPath = cssFiles[cssIndex];
|
||||
const uint64_t dedupKey = dedupKeys[cssIndex];
|
||||
if (dedupKey != 0) {
|
||||
if (std::find(seenKeys.begin(), seenKeys.end(), dedupKey) != seenKeys.end()) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
seenKeys.push_back(dedupKey);
|
||||
}
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
@@ -312,7 +348,8 @@ void Epub::parseCssFiles() const {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files (%zu identical duplicates skipped)", cssParser->ruleCount(),
|
||||
cssFiles.size(), skippedDuplicates);
|
||||
cssParser->clear();
|
||||
}
|
||||
|
||||
@@ -728,14 +765,29 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
return content;
|
||||
}
|
||||
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize,
|
||||
const bool allowEarlyStop) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
|
||||
}
|
||||
|
||||
bool Epub::extractItemToFile(const std::string& itemHref, const std::string& destPath) const {
|
||||
HalFile out;
|
||||
if (!Storage.openFileForWrite("EBP", destPath, out)) {
|
||||
return false;
|
||||
}
|
||||
const bool ok = readItemContentsToStream(itemHref, out, 4096);
|
||||
out.flush();
|
||||
out.close();
|
||||
if (!ok) {
|
||||
Storage.remove(destPath.c_str());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
||||
|
||||
+4
-1
@@ -59,7 +59,10 @@ class Epub {
|
||||
bool generateThumbBmp(int height) const;
|
||||
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||
bool trailingNullByte = false) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize,
|
||||
bool allowEarlyStop = false) const;
|
||||
// Extract an item to a file on SD. On failure the partial file is removed.
|
||||
bool extractItemToFile(const std::string& itemHref, const std::string& destPath) const;
|
||||
bool getItemSize(const std::string& itemHref, size_t* size) const;
|
||||
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
|
||||
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
|
||||
|
||||
@@ -17,7 +17,10 @@ namespace {
|
||||
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
|
||||
// measures the shaped visual text); cached word positions from v29 no longer
|
||||
// match what drawText renders.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 31;
|
||||
// v32: ImageBlock serializes the book-internal source href after the cache path
|
||||
// (lazy extraction: images are header-probed at build time and extracted on
|
||||
// first render).
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 32;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
#include "Epub/converters/DirectPixelWriter.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
@@ -15,8 +18,16 @@
|
||||
// - uint16_t height
|
||||
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), width(width), height(height) {}
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), srcPath(srcPath), width(width), height(height) {}
|
||||
|
||||
void* ImageBlock::extractCtx = nullptr;
|
||||
ImageBlock::ExtractFn ImageBlock::extractFn = nullptr;
|
||||
|
||||
void ImageBlock::setExtractor(void* ctx, ExtractFn fn) {
|
||||
extractCtx = ctx;
|
||||
extractFn = fn;
|
||||
}
|
||||
|
||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||
|
||||
@@ -79,8 +90,113 @@ void rememberImageFailure(const std::string& path) {
|
||||
failedImageHashes[failedImageCount++] = imagePathHash(path);
|
||||
}
|
||||
|
||||
// --- Per-page-render RAM slot for the pixel cache ----------------------------
|
||||
// The tiled grayscale flow re-renders an image page once for the BW
|
||||
// double-refresh and again for every band of both gray planes, and each pass
|
||||
// re-read the whole .pxc off SD (~100 ms for a full-page image, ~13 passes).
|
||||
// Column clipping cannot reduce the SD traffic: the row stride (~100 B) is
|
||||
// smaller than an SD sector, so every sector is touched regardless of the band
|
||||
// window. Instead the first pass loads the payload into RAM and later passes
|
||||
// render from it. Chunked allocation because a single full-image block (up to
|
||||
// 96 KB) rarely fits the fragmented mid-render heap; each chunk is heap-gated
|
||||
// and any failure falls back to the streaming path unchanged. The reader
|
||||
// releases the slot when the page render completes, so nothing stays resident
|
||||
// across page turns.
|
||||
constexpr size_t PXC_CHUNK_SHIFT = 14; // 16 KB chunks
|
||||
constexpr size_t PXC_CHUNK_SIZE = 1u << PXC_CHUNK_SHIFT;
|
||||
constexpr size_t PXC_MAX_CHUNKS = 6; // 96 KB: a full-screen 2bpp image
|
||||
constexpr size_t PXC_HEAP_RESERVE = 24 * 1024;
|
||||
constexpr size_t PXC_MAX_ALLOC_RESERVE = 8 * 1024;
|
||||
// Rows can straddle a chunk boundary; they are reassembled into a stack
|
||||
// buffer. (screenWidth + 3) / 4 caps at 200 B for an 800px panel.
|
||||
constexpr int PXC_MAX_BYTES_PER_ROW = 208;
|
||||
|
||||
std::unique_ptr<uint8_t[]> pxcChunks[PXC_MAX_CHUNKS];
|
||||
uint64_t pxcSlotHash = 0;
|
||||
uint16_t pxcSlotWidth = 0;
|
||||
uint16_t pxcSlotHeight = 0;
|
||||
|
||||
void releasePxcSlot() {
|
||||
for (auto& chunk : pxcChunks) chunk.reset();
|
||||
pxcSlotHash = 0;
|
||||
pxcSlotWidth = 0;
|
||||
pxcSlotHeight = 0;
|
||||
}
|
||||
|
||||
const uint8_t* pxcRowPtr(size_t rowStart, int bytesPerRow, uint8_t* tempRow) {
|
||||
const size_t chunk = rowStart >> PXC_CHUNK_SHIFT;
|
||||
const size_t offset = rowStart & (PXC_CHUNK_SIZE - 1);
|
||||
if (offset + bytesPerRow <= PXC_CHUNK_SIZE) {
|
||||
return pxcChunks[chunk].get() + offset;
|
||||
}
|
||||
const size_t firstPart = PXC_CHUNK_SIZE - offset;
|
||||
memcpy(tempRow, pxcChunks[chunk].get() + offset, firstPart);
|
||||
memcpy(tempRow + firstPart, pxcChunks[chunk + 1].get(), bytesPerRow - firstPart);
|
||||
return tempRow;
|
||||
}
|
||||
|
||||
// cacheFile is positioned just past the header. True when the slot holds the
|
||||
// full pixel payload for this cache path afterward.
|
||||
bool loadPxcSlot(uint64_t cacheHash, HalFile& cacheFile, uint16_t cachedWidth, uint16_t cachedHeight, int bytesPerRow) {
|
||||
releasePxcSlot();
|
||||
if (bytesPerRow > PXC_MAX_BYTES_PER_ROW) {
|
||||
return false;
|
||||
}
|
||||
size_t remaining = (size_t)bytesPerRow * cachedHeight;
|
||||
const size_t chunkCount = (remaining + PXC_CHUNK_SIZE - 1) >> PXC_CHUNK_SHIFT;
|
||||
if (chunkCount == 0 || chunkCount > PXC_MAX_CHUNKS) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < chunkCount; i++) {
|
||||
const size_t want = remaining < PXC_CHUNK_SIZE ? remaining : PXC_CHUNK_SIZE;
|
||||
if (ESP.getFreeHeap() < remaining + PXC_HEAP_RESERVE || ESP.getMaxAllocHeap() < want + PXC_MAX_ALLOC_RESERVE) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
pxcChunks[i] = makeUniqueNoThrow<uint8_t[]>(want);
|
||||
if (!pxcChunks[i] || cacheFile.read(pxcChunks[i].get(), want) != static_cast<int>(want)) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
remaining -= want;
|
||||
}
|
||||
pxcSlotHash = cacheHash;
|
||||
pxcSlotWidth = cachedWidth;
|
||||
pxcSlotHeight = cachedHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
void renderRowsFromPxcSlot(GfxRenderer& renderer, int x, int y) {
|
||||
const int bytesPerRow = (pxcSlotWidth + 3) / 4;
|
||||
uint8_t tempRow[PXC_MAX_BYTES_PER_ROW];
|
||||
|
||||
DirectPixelWriter pw;
|
||||
pw.init(renderer);
|
||||
|
||||
for (int row = 0; row < pxcSlotHeight; row++) {
|
||||
const uint8_t* rowBuffer = pxcRowPtr((size_t)row * bytesPerRow, bytesPerRow, tempRow);
|
||||
pw.beginRow(y + row);
|
||||
int colStart, colEnd;
|
||||
pw.bandColRange(x, pxcSlotWidth, colStart, colEnd);
|
||||
for (int col = colStart; col < colEnd; col++) {
|
||||
const int byteIdx = col >> 2; // col / 4
|
||||
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
|
||||
const uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||
pw.writePixel(x + col, pixelValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
int expectedHeight) {
|
||||
// A later pass of the same page render: the payload is already in RAM, skip
|
||||
// the file entirely.
|
||||
const uint64_t cacheHash = imagePathHash(cachePath);
|
||||
if (pxcSlotHash == cacheHash && pxcSlotWidth != 0) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
HalFile cacheFile;
|
||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||
return false;
|
||||
@@ -98,12 +214,24 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// Read several rows per SD access. A full-page image is re-rendered on every
|
||||
// grayscale strip pass (~14x per page), and a one-row-per-read loop here means
|
||||
// cachedHeight (~728) tiny reads through the storage mutex + SdFat each time —
|
||||
// the dominant cost of displaying an image page. Batching rows into a ~4KB
|
||||
// buffer cuts that to ~20 reads per pass without holding the whole image.
|
||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
|
||||
// First pass of a page render: try to pull the payload into the RAM slot so
|
||||
// the remaining ~12 passes skip SD entirely.
|
||||
if (loadPxcSlot(cacheHash, cacheFile, cachedWidth, cachedHeight, bytesPerRow)) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
LOG_DBG("IMG", "Cache render complete (payload now in RAM)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Streaming fallback (slot didn't fit). A failed slot load may have consumed
|
||||
// part of the payload; rewind to just past the header.
|
||||
cacheFile.seek(4);
|
||||
|
||||
// Read several rows per SD access. A one-row-per-read loop here means
|
||||
// cachedHeight (~728) tiny reads through the storage mutex + SdFat; batching
|
||||
// rows into a ~4KB buffer cuts that to ~20 reads per pass without holding the
|
||||
// whole image.
|
||||
int rowsPerRead = 4096 / bytesPerRow;
|
||||
if (rowsPerRead < 1) rowsPerRead = 1;
|
||||
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
|
||||
@@ -176,6 +304,8 @@ bool ImageBlock::needsDecode() const { return !imageFailedThisSession(imagePath)
|
||||
|
||||
void ImageBlock::clearSessionRenderFailures() { failedImageCount = 0; }
|
||||
|
||||
void ImageBlock::releaseRenderCache() { releasePxcSlot(); }
|
||||
|
||||
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
|
||||
renderer.fillRect(x, y, width, height, true);
|
||||
if (width > 2 && height > 2) {
|
||||
@@ -225,6 +355,15 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
return; // Successfully rendered from cache
|
||||
}
|
||||
|
||||
// The build only header-probed the image for dimensions; pull the actual
|
||||
// file out of the book now, on first visit to the page.
|
||||
if (!srcPath.empty() && extractFn && !Storage.exists(imagePath.c_str())) {
|
||||
LOG_DBG("IMG", "Lazy-extracting %s -> %s", srcPath.c_str(), imagePath.c_str());
|
||||
if (!extractFn(extractCtx, srcPath.c_str(), imagePath.c_str())) {
|
||||
LOG_ERR("IMG", "Lazy extraction failed: %s", srcPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// No cache - need to decode the image
|
||||
// Check if image file exists
|
||||
HalFile file;
|
||||
@@ -280,6 +419,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
|
||||
bool ImageBlock::serialize(HalFile& file) {
|
||||
serialization::writeString(file, imagePath);
|
||||
serialization::writeString(file, srcPath);
|
||||
serialization::writePod(file, width);
|
||||
serialization::writePod(file, height);
|
||||
return true;
|
||||
@@ -287,9 +427,11 @@ bool ImageBlock::serialize(HalFile& file) {
|
||||
|
||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
|
||||
std::string path;
|
||||
std::string src;
|
||||
serialization::readString(file, path);
|
||||
serialization::readString(file, src);
|
||||
int16_t w, h;
|
||||
serialization::readPod(file, w);
|
||||
serialization::readPod(file, h);
|
||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||
return std::unique_ptr<ImageBlock>(new (std::nothrow) ImageBlock(path, src, w, h));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
class ImageBlock final : public Block {
|
||||
public:
|
||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||
ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height);
|
||||
~ImageBlock() override = default;
|
||||
|
||||
const std::string& getImagePath() const { return imagePath; }
|
||||
@@ -21,6 +21,21 @@ class ImageBlock final : public Block {
|
||||
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
|
||||
static void clearSessionRenderFailures();
|
||||
|
||||
// A page render draws its image up to ~13 times (BW double-refresh plus every
|
||||
// grayscale band pass), and each draw streams the whole .pxc off SD. The
|
||||
// first draw caches the pixel payload in RAM (chunked, heap-gated, falls back
|
||||
// to streaming when it doesn't fit); the reader calls this when the page
|
||||
// render completes so nothing stays resident between pages.
|
||||
static void releaseRenderCache();
|
||||
|
||||
// Lazy extraction hook: the section build only header-probes images for their
|
||||
// dimensions; the file at imagePath is extracted out of the book on first
|
||||
// render, via this callback (function pointer + context, not std::function —
|
||||
// this is render-loop code). Registered by the reader activity that owns the
|
||||
// Epub, cleared on its exit.
|
||||
using ExtractFn = bool (*)(void* ctx, const char* srcPath, const char* destPath);
|
||||
static void setExtractor(void* ctx, ExtractFn fn);
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
@@ -30,6 +45,10 @@ class ImageBlock final : public Block {
|
||||
|
||||
private:
|
||||
std::string imagePath;
|
||||
std::string srcPath; // book-internal source href; empty once known-extracted
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
|
||||
static void* extractCtx;
|
||||
static ExtractFn extractFn;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "ImageDimsProbe.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace {
|
||||
// SOFn markers carry the frame dimensions. C4 (DHT), C8 (JPG extension) and
|
||||
// CC (DAC) share the 0xCn range but are not frame headers.
|
||||
bool isJpegSof(const uint8_t marker) {
|
||||
return marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||
}
|
||||
constexpr uint8_t PNG_SIG[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
} // namespace
|
||||
|
||||
bool ImageDimsProbe::feed(const uint8_t b) {
|
||||
switch (state) {
|
||||
case State::Sniff:
|
||||
if (b == 0xFF) {
|
||||
state = State::JpegSoi;
|
||||
} else if (b == PNG_SIG[0]) {
|
||||
state = State::PngHeader;
|
||||
} else {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
pos = 1;
|
||||
return true;
|
||||
|
||||
case State::PngHeader:
|
||||
// Bytes 1..7: signature; 8..11: IHDR length; 12..15: "IHDR"; 16..23: dims.
|
||||
if (pos < 8) {
|
||||
if (b != PNG_SIG[pos]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 12 && pos < 16) {
|
||||
if (b != "IHDR"[pos - 12]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 16 && pos < 20) {
|
||||
width = static_cast<uint16_t>((static_cast<uint32_t>(width) << 8) | b);
|
||||
} else if (pos >= 20 && pos < 24) {
|
||||
height = static_cast<uint16_t>((static_cast<uint32_t>(height) << 8) | b);
|
||||
if (pos == 23) {
|
||||
state = State::Done;
|
||||
pos++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
return true;
|
||||
|
||||
case State::JpegSoi:
|
||||
if (b != 0xD8) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegFf:
|
||||
if (b != 0xFF) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegMarker;
|
||||
return true;
|
||||
|
||||
case State::JpegMarker:
|
||||
if (b == 0xFF) return true; // fill bytes before a marker are legal
|
||||
if (b == 0x01 || (b >= 0xD0 && b <= 0xD8)) {
|
||||
// TEM / RSTn / SOI: standalone, no length field.
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
}
|
||||
if (b == 0xD9 || b == 0xDA) {
|
||||
// EOI or SOS before any SOF: no dimensions to be found.
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
sofPending = isJpegSof(b);
|
||||
state = State::JpegLenHi;
|
||||
return true;
|
||||
|
||||
case State::JpegLenHi:
|
||||
segLen = static_cast<uint16_t>(b << 8);
|
||||
state = State::JpegLenLo;
|
||||
return true;
|
||||
|
||||
case State::JpegLenLo:
|
||||
segLen = static_cast<uint16_t>(segLen | b);
|
||||
if (segLen < 2 || (sofPending && segLen < 7)) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
if (sofPending) {
|
||||
sofFill = 0;
|
||||
state = State::JpegSof;
|
||||
} else if (segLen == 2) {
|
||||
state = State::JpegFf;
|
||||
} else {
|
||||
skipLeft = static_cast<uint32_t>(segLen) - 2;
|
||||
state = State::JpegSkip;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::JpegSkip:
|
||||
if (--skipLeft == 0) state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegSof:
|
||||
sofBuf[sofFill++] = b;
|
||||
if (sofFill == 5) {
|
||||
height = static_cast<uint16_t>((sofBuf[1] << 8) | sofBuf[2]);
|
||||
width = static_cast<uint16_t>((sofBuf[3] << 8) | sofBuf[4]);
|
||||
state = State::Done;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::Done:
|
||||
case State::Failed:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t b) { return feed(b) ? 1 : 0; }
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t* data, const size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (!feed(data[i])) return i; // short write: polite early stop
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
bool ImageDimsProbe::getDimensions(ImageDimensions& out) const {
|
||||
if (state != State::Done || width == 0 || height == 0 || width > INT16_MAX || height > INT16_MAX) {
|
||||
return false;
|
||||
}
|
||||
out.width = static_cast<int16_t>(width);
|
||||
out.height = static_cast<int16_t>(height);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include <Print.h>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
// Streaming JPEG/PNG header parser: finds image dimensions from the first few
|
||||
// KB of a compressed stream without inflating the whole image. Feed bytes via
|
||||
// the Print interface (e.g. Epub::readItemContentsToStream with
|
||||
// allowEarlyStop=true); write() returns short once the dimensions are known or
|
||||
// the stream is known to be unusable, which the zip layer treats as a polite
|
||||
// early stop rather than an error.
|
||||
//
|
||||
// JPEG: walks marker segments (skipping EXIF/APPn of any size statefully, so
|
||||
// nothing is buffered) until a SOFn frame header yields the dimensions.
|
||||
// PNG: reads the IHDR fields at their fixed offsets (bytes 16..23).
|
||||
class ImageDimsProbe : public Print {
|
||||
public:
|
||||
size_t write(uint8_t b) override;
|
||||
size_t write(const uint8_t* data, size_t len) override;
|
||||
|
||||
// True only when a valid header was found; fills `out`.
|
||||
bool getDimensions(ImageDimensions& out) const;
|
||||
|
||||
private:
|
||||
bool feed(uint8_t b); // returns false once parsing is finished (found or failed)
|
||||
|
||||
enum class State : uint8_t {
|
||||
Sniff, // first byte decides the format
|
||||
PngHeader, // PNG signature + IHDR at fixed offsets
|
||||
JpegSoi, // second SOI byte (0xD8)
|
||||
JpegFf, // expect a 0xFF marker prefix
|
||||
JpegMarker, // marker type byte (0xFF padding allowed)
|
||||
JpegLenHi, // segment length, high byte
|
||||
JpegLenLo, // segment length, low byte
|
||||
JpegSkip, // skipping a non-SOF segment body
|
||||
JpegSof, // collecting the 5 SOF bytes: precision, height(2), width(2)
|
||||
Done,
|
||||
Failed,
|
||||
};
|
||||
State state = State::Sniff;
|
||||
uint32_t pos = 0; // absolute stream offset (PNG fixed-offset parsing)
|
||||
uint32_t skipLeft = 0; // remaining segment bytes to skip
|
||||
uint16_t segLen = 0;
|
||||
bool sofPending = false; // current segment is a SOF frame header
|
||||
uint8_t sofBuf[5] = {0};
|
||||
uint8_t sofFill = 0;
|
||||
uint16_t width = 0;
|
||||
uint16_t height = 0;
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "Epub.h"
|
||||
#include "Epub/Page.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
#include "Epub/converters/ImageDimsProbe.h"
|
||||
#include "Epub/converters/ImageToFramebufferDecoder.h"
|
||||
#include "Epub/htmlEntities.h"
|
||||
|
||||
@@ -554,28 +555,47 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
|
||||
|
||||
// Extract image to cache file
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
|
||||
if (extractSuccess) {
|
||||
// Get image dimensions, retrying to absorb SD-card sync latency on slow
|
||||
// cards. Replaces a blanket delay(50) that cost ~50ms on every image, and
|
||||
// closes the silent-drop bug where a single getDimensions failure was fatal.
|
||||
{
|
||||
// Probe the dimensions from the entry's first bytes (early-aborted
|
||||
// inflate, a few KB) instead of extracting the whole image now —
|
||||
// extraction is deferred to the first render of the page (see
|
||||
// ImageBlock's lazy extractor). This is what keeps first-open of an
|
||||
// image-heavy chapter from stalling for seconds per image.
|
||||
ImageDimensions dims = {0, 0};
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
bool gotDimensions = false;
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
ImageDimsProbe headerProbe;
|
||||
self->epub->readItemContentsToStream(resolvedPath, headerProbe, 1024, /*allowEarlyStop=*/true);
|
||||
bool gotDimensions = headerProbe.getDimensions(dims);
|
||||
|
||||
if (!gotDimensions) {
|
||||
// No header within the stream (rare) — fall back to extracting the
|
||||
// whole image and probing the file. That can take seconds, so
|
||||
// surface the indexing popup first (single-shot per parser).
|
||||
if (self->popupFn && !self->imagePopupFired) {
|
||||
self->imagePopupFired = true;
|
||||
self->popupFn();
|
||||
}
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
if (extractSuccess) {
|
||||
// Retry to absorb SD-card sync latency on slow cards, and to close
|
||||
// the silent-drop bug where a single getDimensions failure was fatal.
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
|
||||
if (gotDimensions) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
@@ -722,7 +742,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentPageNextY += imageMarginTop;
|
||||
|
||||
// Create ImageBlock and add to page
|
||||
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||
auto imageBlock =
|
||||
std::make_shared<ImageBlock>(cachedImagePath, resolvedPath, displayWidth, displayHeight);
|
||||
if (!imageBlock) {
|
||||
LOG_ERR("EHP", "Failed to create ImageBlock");
|
||||
return;
|
||||
@@ -753,8 +774,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
LOG_ERR("EHP", "Failed to get image dimensions");
|
||||
Storage.remove(cachedImagePath.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
} // isFormatSupported
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class ChapterHtmlSlimParser {
|
||||
GfxRenderer& renderer;
|
||||
std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)> completePageFn;
|
||||
std::function<void()> popupFn; // Popup callback
|
||||
bool imagePopupFired = false; // popupFn fired for the first image probe (single-shot)
|
||||
int depth = 0;
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
|
||||
@@ -22,7 +22,21 @@ constexpr char DEVICE_ID[] = "crosspoint-reader";
|
||||
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
|
||||
// floor. Check both total free heap and largest contiguous block so fragmented
|
||||
// heap does not fall through into a failed TLS allocation path.
|
||||
constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
|
||||
// MEMFIX-PORT: TLS heap gate; portable
|
||||
// Field data (July 2026): launching sync from a reader session lands at
|
||||
// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles
|
||||
// allocation failure by returning MEMORY_E (no abort under -fno-exceptions),
|
||||
// so an optimistic attempt degrades to the same clean "sync failed" as the
|
||||
// gate — the gate only needs to keep out states where a doomed handshake
|
||||
// would waste tens of seconds, not guarantee success.
|
||||
//
|
||||
// Free and largest-block have separate requirements: with SP ECC
|
||||
// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so
|
||||
// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not
|
||||
// a run of fast-math bignums. A handshake was measured succeeding inside a
|
||||
// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit.
|
||||
constexpr uint32_t MIN_FREE_FOR_TLS = 50000;
|
||||
constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000;
|
||||
|
||||
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native
|
||||
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
|
||||
@@ -39,9 +53,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) {
|
||||
bool insufficientHeap() {
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
|
||||
maxAllocHeap, MIN_HEAP_FOR_TLS);
|
||||
if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap,
|
||||
MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -437,7 +437,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize, const bool allowEarlyStop) {
|
||||
const ScopedOpenClose zip{*this};
|
||||
if (!zip) return false;
|
||||
|
||||
@@ -469,8 +469,9 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (out.write(buffer, dataRead) != dataRead) {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
free(buffer);
|
||||
if (allowEarlyStop) return true; // sink has what it needs
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
return false;
|
||||
}
|
||||
remaining -= dataRead;
|
||||
@@ -525,7 +526,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
|
||||
if (produced > 0) {
|
||||
if (out.write(outputBuffer, produced) != produced) {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
if (allowEarlyStop) {
|
||||
success = true; // sink has what it needs
|
||||
} else {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -69,7 +69,10 @@ class ZipFile {
|
||||
// Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple
|
||||
// These functions will open and close the zip as needed
|
||||
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false);
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize);
|
||||
// allowEarlyStop: a short write from `out` is treated as the sink asking to
|
||||
// stop (returns true) instead of a write failure — used by header probes
|
||||
// that only need the first bytes of an entry.
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize, bool allowEarlyStop = false);
|
||||
|
||||
template <typename F>
|
||||
bool enumerateFilePaths(F&& callback) {
|
||||
@@ -80,6 +83,14 @@ class ZipFile {
|
||||
return true;
|
||||
}
|
||||
|
||||
return enumerateFileEntries([&callback](std::string_view path, uint32_t, uint32_t) { callback(path); });
|
||||
}
|
||||
|
||||
// Callback receives (path, crc32, compressedSize) for each central-directory
|
||||
// entry. Always scans the central directory: the slim-stat cache does not
|
||||
// hold CRCs.
|
||||
template <typename F>
|
||||
bool enumerateFileEntries(F&& callback) {
|
||||
const bool wasOpen = isOpen();
|
||||
if (!wasOpen && !open()) {
|
||||
return false;
|
||||
@@ -103,7 +114,11 @@ class ZipFile {
|
||||
break;
|
||||
}
|
||||
|
||||
file.seekCur(24);
|
||||
file.seekCur(12);
|
||||
uint32_t crc32, compressedSize;
|
||||
file.read(&crc32, 4);
|
||||
file.read(&compressedSize, 4);
|
||||
file.seekCur(4);
|
||||
uint16_t nameLen, m, k;
|
||||
file.read(&nameLen, 2);
|
||||
file.read(&m, 2);
|
||||
@@ -113,7 +128,7 @@ class ZipFile {
|
||||
if (nameLen < sizeof(itemName)) {
|
||||
file.read(itemName, nameLen);
|
||||
itemName[nameLen] = '\0';
|
||||
callback(std::string_view{itemName, nameLen});
|
||||
callback(std::string_view{itemName, nameLen}, crc32, compressedSize);
|
||||
} else {
|
||||
file.seekCur(nameLen);
|
||||
}
|
||||
|
||||
+57
-2
@@ -13,7 +13,10 @@ framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
check_tool = cppcheck
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||
; project header as missing (~400 information-level lines) and fails the job.
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
check_skip_packages = yes
|
||||
|
||||
board_upload.flash_size = 16MB
|
||||
@@ -40,7 +43,14 @@ build_flags =
|
||||
-DWOLFSSL_OPTIONS_H
|
||||
-DWOLFSSL_CLIENT_EXAMPLE
|
||||
-DWOLFSSL_TLS13
|
||||
-DWOLFSSL_SP_RISCV32
|
||||
# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation
|
||||
# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums
|
||||
# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of
|
||||
# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session
|
||||
# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster.
|
||||
# SP_SMALL trades the large precomputed point tables for smaller flash.
|
||||
-DWOLFSSL_HAVE_SP_ECC
|
||||
-DWOLFSSL_SP_SMALL
|
||||
-DHAVE_TLS_EXTENSIONS
|
||||
-DHAVE_SUPPORTED_CURVES
|
||||
-DHAVE_HKDF
|
||||
@@ -61,6 +71,51 @@ board_build.flash_mode = dio
|
||||
board_build.flash_size = 16MB
|
||||
board_build.partitions = partitions.csv
|
||||
|
||||
; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the
|
||||
; Arduino core libs on first build (slower once, cached after; needs the CMake
|
||||
; pin in platformio.local.ini on macOS).
|
||||
;
|
||||
; If an interrupted rebuild fails with "multiple definition of 'app_main'"
|
||||
; (stale generated scaffold), clean it up with:
|
||||
; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default
|
||||
; Do NOT use `git clean -fdX` — it deletes platformio.local.ini.
|
||||
custom_sdkconfig =
|
||||
; Task stack right-sizing from measured high-water marks (heap block map +
|
||||
; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across
|
||||
; every capture; the FreeRTOS timer service used ~0.5 KB
|
||||
; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap.
|
||||
CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560
|
||||
; Move the WiFi stack's non-critical hot paths out of IRAM into flash.
|
||||
; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this
|
||||
; frees lands directly in the heap — paid for with lower WiFi throughput
|
||||
; during transfers (occasional sync/OTA use, not streaming: acceptable).
|
||||
; IRAM cost is static, so the heap gain applies even with WiFi off.
|
||||
CONFIG_ESP_WIFI_IRAM_OPT=n
|
||||
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
|
||||
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||
; the core source list; all other bundled libraries default to enabled.
|
||||
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||
; ESP_SR (speech recognition) only exists in the S3 core; its wrapper includes
|
||||
; ESP_I2S.h, which the isolated core rebuild can't resolve. Unused here anyway,
|
||||
; so drop it or the sticky env fails to build.
|
||||
CONFIG_ARDUINO_SELECTIVE_ESP_SR=n
|
||||
|
||||
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||
; require embedded server certs the lib builder can't generate
|
||||
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||
custom_component_remove =
|
||||
espressif/esp_insights
|
||||
espressif/esp_rainmaker
|
||||
espressif/esp_diagnostics
|
||||
espressif/esp_diag_data_store
|
||||
espressif/esp_schedule
|
||||
espressif/esp_rcp_update
|
||||
espressif/esp_secure_cert_mgr
|
||||
espressif/cbor
|
||||
|
||||
extra_scripts =
|
||||
pre:scripts/patch_wolfssl.py
|
||||
pre:scripts/build_html.py
|
||||
|
||||
@@ -12,8 +12,12 @@ OVERRIDES = f"""
|
||||
#ifndef HAVE_FFDHE_2048
|
||||
#define HAVE_FFDHE_2048
|
||||
#endif
|
||||
/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum,
|
||||
ISRG Root X1 included) with half the per-bignum heap of 16384: with
|
||||
WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the
|
||||
heap, and TLS cert verification allocates dozens at once. */
|
||||
#undef FP_MAX_BITS
|
||||
#define FP_MAX_BITS 16384
|
||||
#define FP_MAX_BITS 8192
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,11 @@ void EpubReaderActivity::onEnter() {
|
||||
}
|
||||
|
||||
ImageBlock::clearSessionRenderFailures();
|
||||
// Lazy image extraction: section builds only header-probe images, so the first
|
||||
// render of an image page pulls the file out of the EPUB through this hook.
|
||||
ImageBlock::setExtractor(epub.get(), [](void* ctx, const char* src, const char* dest) {
|
||||
return static_cast<Epub*>(ctx)->extractItemToFile(src, dest);
|
||||
});
|
||||
|
||||
// Configure screen orientation based on settings
|
||||
// NOTE: This affects layout math and must be applied before any render calls.
|
||||
@@ -209,6 +214,10 @@ void EpubReaderActivity::onEnter() {
|
||||
void EpubReaderActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// The extractor holds a raw pointer to this activity's epub; drop it before
|
||||
// the activity (and the shared_ptr) goes away.
|
||||
ImageBlock::setExtractor(nullptr, nullptr);
|
||||
|
||||
// Reset orientation back to portrait for the rest of the UI
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
@@ -258,6 +267,30 @@ void EpubReaderActivity::openReaderMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::buildTickHeapGate() {
|
||||
const size_t freeHeap = ESP.getFreeHeap();
|
||||
const size_t maxBlock = ESP.getMaxAllocHeap();
|
||||
// Below the floors: just wait. The tick is deferrable — page-turn transients
|
||||
// free up between turns and the tick retries every loop pass. Track the
|
||||
// paused state so skipLoopDelay() stops pinning the CPU at full speed while
|
||||
// no build work is actually happening (the gate can stay closed for a long
|
||||
// stretch if the retained build context itself holds the heap down).
|
||||
buildHeapPaused = freeHeap < BACKGROUND_BUILD_MIN_FREE_HEAP || maxBlock < BACKGROUND_BUILD_MIN_MAX_ALLOC;
|
||||
return !buildHeapPaused;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::showBuildPopup() {
|
||||
// Mid-build indexing popup: only during onEnter's blocking build-to-target phase
|
||||
// (buildPopupPending), at most once, and only when the framebuffer isn't on loan.
|
||||
// If it fires while the loan is active (e.g. the parser's size-based call during
|
||||
// startBuild), pending stays set and the deadline check retries after the loan.
|
||||
if (!buildPopupPending || !renderer.hasFrameBuffer()) return;
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts.
|
||||
pagesUntilFullRefresh = 1;
|
||||
buildPopupPending = false;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openDictionaryWordSelect() {
|
||||
if (SETTINGS.dictionaryName[0] == '\0') {
|
||||
showDictionaryMessage = true;
|
||||
@@ -288,6 +321,40 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
|
||||
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
|
||||
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
|
||||
// cached. Debounced past rapid page-flipping, one attempt per position, and
|
||||
// deferred while a render/build owns the CPU or the heap is at the render
|
||||
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
|
||||
// section isn't loaded).
|
||||
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
|
||||
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
|
||||
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
|
||||
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC &&
|
||||
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
|
||||
RenderLock lock; // the page table must not change under the scan
|
||||
// Re-check under the lock: peek() and acquisition are not atomic, so the render
|
||||
// task may have reset/replaced the section or moved the page in between.
|
||||
if (section && !section->isBuilding() &&
|
||||
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
|
||||
idlePrewarmSpine = currentSpineIndex;
|
||||
idlePrewarmPage = section->currentPage;
|
||||
const int nextPage = section->currentPage + 1;
|
||||
if (nextPage < static_cast<int>(section->pageCount)) {
|
||||
if (const auto p = section->loadPage(nextPage)) {
|
||||
if (auto* fcm = renderer.getFontCacheManager()) {
|
||||
const auto t0 = millis();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
|
||||
scope.endScanAndPrewarm();
|
||||
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
|
||||
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
|
||||
// session, so reopening a partial deliberately does NOT start it (see the deferral in
|
||||
@@ -323,14 +390,17 @@ void EpubReaderActivity::loop() {
|
||||
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
|
||||
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
|
||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) {
|
||||
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
|
||||
buildTickHeapGate()) {
|
||||
RenderLock lock;
|
||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
||||
// mutation, so it flags this as always true.
|
||||
// buildSomeMore() would fail and wrongly reset the section. The heap gate must be re-read
|
||||
// too: a render that won the lock race can expand retained glyph buffers, invalidating the
|
||||
// pre-lock heap reading. cppcheck can't see the cross-task mutation, so it flags this as
|
||||
// always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding()) {
|
||||
if (section->isBuilding() && buildTickHeapGate()) {
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
@@ -1134,18 +1204,29 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
// Lend the framebuffer's 48 KB to the blocking pre-render burst
|
||||
// (startBuild inflates the whole spine HTML — the memory peak). The
|
||||
// background buildSomeMore chunks in loop() do NOT get the loan: they
|
||||
// deliberately interleave with page renders. Restored before render.
|
||||
GfxRenderer::FrameBufferLoan loan(renderer);
|
||||
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
// Mid-build popup surfacing for slow builds the predictive gates can't
|
||||
// see (image extraction/probing inside a single page, or any chunk
|
||||
// overrunning the deadline). The parser fires the callback before the
|
||||
// first image probe; buildPopupPending gates it to this blocking phase
|
||||
// so a background build in loop() can never draw over a displayed page.
|
||||
buildPopupPending = !showPopup;
|
||||
const unsigned long buildStartMs = millis();
|
||||
bool started;
|
||||
{
|
||||
// Lend the framebuffer's 48 KB to startBuild only (the spine HTML
|
||||
// inflation peak). The chunk loop below runs without it so the popup
|
||||
// can draw mid-build; background chunks never had the loan either.
|
||||
GfxRenderer::FrameBufferLoan loan(renderer);
|
||||
started = section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled,
|
||||
[this] { showBuildPopup(); });
|
||||
}
|
||||
if (!started) {
|
||||
LOG_ERR("ERS", "Failed to start section build");
|
||||
section.reset();
|
||||
loan.end(); // restore before anything draws (showBuildError renders a popup)
|
||||
buildPopupPending = false;
|
||||
showBuildError();
|
||||
return;
|
||||
}
|
||||
@@ -1154,15 +1235,19 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
||||
if (buildPopupPending && millis() - buildStartMs >= BUILD_POPUP_DEADLINE_MS) {
|
||||
// The predictive gates guessed fast but the build blew the silent budget.
|
||||
showBuildPopup();
|
||||
}
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
loan.end(); // restore before anything draws (showBuildError renders a popup)
|
||||
buildPopupPending = false;
|
||||
showBuildError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
loan.end();
|
||||
buildPopupPending = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1323,6 +1408,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
const auto start = millis();
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
lastRenderCompleteMs = millis();
|
||||
}
|
||||
// Only persist when the position actually changed. render() also runs on menu,
|
||||
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
|
||||
@@ -1385,6 +1471,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const auto t0 = millis();
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
|
||||
// The image pixel-cache RAM slot lives for exactly one page render (it feeds
|
||||
// the BW double-refresh and every grayscale band pass); release it on every
|
||||
// exit so nothing stays resident across page turns.
|
||||
struct PxcSlotGuard {
|
||||
~PxcSlotGuard() { ImageBlock::releaseRenderCache(); }
|
||||
} pxcSlotGuard;
|
||||
|
||||
// Font prewarm: scan pass accumulates text, then prewarm, then real render
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
@@ -1398,9 +1491,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
|
||||
const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale();
|
||||
// Whole-plane buffering only pays when the BW refresh genuinely runs async
|
||||
// underneath it; on blocking panels it would just spend ~50 KB for the
|
||||
// identical serial timing.
|
||||
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh();
|
||||
// underneath it; on blocking panels (X3) it would just spend ~50 KB for the
|
||||
// identical serial timing. Image pages take the blocking double-FAST path
|
||||
// below (no async refresh is ever started), so they'd spend the buffers with
|
||||
// nothing in flight to overlap.
|
||||
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh() && !pageHasImages;
|
||||
auto renderGrayscalePass = [&]() {
|
||||
if (needsTextGrayscale) {
|
||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||
@@ -1446,9 +1541,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// regardless of residue.
|
||||
pagesUntilFullRefresh = 1;
|
||||
} else {
|
||||
// Deferred when a tiled grayscale pass follows: the plane rendering below
|
||||
// then overlaps the panel's refresh time instead of following it.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh);
|
||||
// Async form: start the waveform and return so the grayscale plane rendering
|
||||
// below overlaps the panel's refresh time instead of following it.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, overlapRefresh);
|
||||
}
|
||||
const auto tDisplay = millis();
|
||||
|
||||
@@ -1483,12 +1578,25 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// Tiered on heap pressure: two plane buffers hide both plane renders
|
||||
// inside the refresh wait; one hides the LSB render (its buffer is reused
|
||||
// for MSB after streaming); none falls back to the strip-scratch flow with
|
||||
// no overlap. The MSB buffer is only attempted when it leaves ~60 KB free
|
||||
// so the pass never starves concurrent allocations. Blocking panels skip
|
||||
// the buffers entirely (nothing to overlap).
|
||||
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
auto msbPlaneBuf =
|
||||
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
// no overlap. Each buffer is only attempted when it leaves ~60 KB free so
|
||||
// the pass never starves concurrent allocations: the next page re-render
|
||||
// allocates through throwing std::string paths that abort() on OOM under
|
||||
// -fno-exceptions, so a plane buffer that "fits" but eats the render
|
||||
// headroom is worse than the strip fallback. Blocking panels skip the
|
||||
// buffers entirely (nothing to overlap).
|
||||
constexpr size_t PLANE_BUF_HEADROOM = 60000;
|
||||
// Free-heap alone ignores fragmentation: taking the largest block for a
|
||||
// plane can leave only slivers behind even when total headroom looks fine.
|
||||
// Require the block to fit the plane with 16 KB contiguous to spare, which
|
||||
// also keeps the advance-table batch scratch viable mid-render (same
|
||||
// rationale as BACKGROUND_BUILD_MIN_MAX_ALLOC).
|
||||
constexpr size_t PLANE_BUF_MAX_ALLOC_RESERVE = 16 * 1024;
|
||||
const auto planeBufFits = [planeBytes] {
|
||||
return ESP.getFreeHeap() >= planeBytes + PLANE_BUF_HEADROOM &&
|
||||
ESP.getMaxAllocHeap() >= planeBytes + PLANE_BUF_MAX_ALLOC_RESERVE;
|
||||
};
|
||||
auto lsbPlaneBuf = (overlapRefresh && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
auto msbPlaneBuf = (lsbPlaneBuf && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
|
||||
if (lsbPlaneBuf) {
|
||||
renderPlaneToBuffer(true, lsbPlaneBuf.get());
|
||||
@@ -1522,13 +1630,20 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
|
||||
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
|
||||
} else {
|
||||
// Per-strip scratch tier: blocking panels and the OOM fallback. The
|
||||
// strip writes below need the panel idle, so wait out any pending async
|
||||
// refresh first (no-op on blocking panels).
|
||||
// Per-strip scratch tier: blocking panels (X3) and the OOM fallback.
|
||||
// The strip writes below need the panel idle, so wait out any pending
|
||||
// async refresh first (no-op on blocking panels).
|
||||
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
|
||||
renderer.waitRefreshComplete();
|
||||
if (!scratch) {
|
||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||
if (overlapRefresh) {
|
||||
// The BW refresh ran the shadow-free async path, so controller RAM's
|
||||
// differential baseline was never rebuilt. Even with AA skipped it must
|
||||
// be re-synced from the intact BW framebuffer, or the next differential
|
||||
// update diffs against stale contents.
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
}
|
||||
} else {
|
||||
// Bands may be streamed in any order: X4 windows each via setRamArea,
|
||||
// X3 via PTL.
|
||||
|
||||
@@ -44,6 +44,13 @@ class EpubReaderActivity final : public Activity {
|
||||
unsigned long dictionaryMessageTime = 0UL;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
|
||||
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
|
||||
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
|
||||
// SD reads on the page-turn critical path. One attempt per position.
|
||||
int idlePrewarmSpine = -1;
|
||||
int idlePrewarmPage = -1;
|
||||
unsigned long lastRenderCompleteMs = 0;
|
||||
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
|
||||
std::vector<BookmarkEntry> cachedBookmarks;
|
||||
// Tracks whether this book is currently removed from Recent Books by the
|
||||
@@ -89,6 +96,33 @@ class EpubReaderActivity final : public Activity {
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
|
||||
// MEMFIX-PORT: background-build heap floor; portable
|
||||
// Skip background build ticks below this free-heap floor. The parse path grows
|
||||
// word vectors of heap strings — throwing allocations that abort() on OOM under
|
||||
// -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a
|
||||
// background tick under heap pressure). The tick is deferrable work:
|
||||
// page-turn transients free up between turns and the build resumes; the render
|
||||
// path still builds the page it actually needs regardless of this floor.
|
||||
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024;
|
||||
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
|
||||
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
|
||||
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
|
||||
// any single allocation can actually have it. 16 KB also keeps the advance-table
|
||||
// batch path (16 KB scratch) viable during builds.
|
||||
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
|
||||
// Gate for a background build tick: true when the heap can take parse allocations.
|
||||
// Updates buildHeapPaused as a side effect.
|
||||
bool buildTickHeapGate();
|
||||
// True while the background build is gated on the heap floors. Lets skipLoopDelay()
|
||||
// return the loop to normal delay/power-saving during the pause: isBuilding() stays
|
||||
// true the whole time, and without this the loop would spin at full CPU speed doing
|
||||
// no build work — indefinitely, if the build context itself keeps the heap low.
|
||||
bool buildHeapPaused = false;
|
||||
// Heap floor for optional render-adjacent work (idle prewarm). Page
|
||||
// deserialization (TextBlock word vectors/strings) and glyph caching allocate
|
||||
// through throwing paths that abort() on OOM; skip deferrable work below it.
|
||||
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
@@ -112,6 +146,16 @@ class EpubReaderActivity final : public Activity {
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Deadline backstop for the predictive gates above: if the blocking build-to-target still
|
||||
// hasn't produced the landing page this long after the build started, surface the popup
|
||||
// mid-build. Builds that finish under the deadline stay popup-free.
|
||||
static constexpr unsigned long BUILD_POPUP_DEADLINE_MS = 1000;
|
||||
// True only during onEnter's blocking build-to-target phase, until the popup has been
|
||||
// drawn. Gates showBuildPopup() so the parser's popup callback (which persists into
|
||||
// background buildSomeMore chunks) can never draw over a displayed page.
|
||||
bool buildPopupPending = false;
|
||||
// Draw the indexing popup mid-build (parser image-probe callback and deadline backstop).
|
||||
void showBuildPopup();
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
@@ -147,8 +191,10 @@ class EpubReaderActivity final : public Activity {
|
||||
// Full CPU speed + fast loop ticks while a section build runs: at the low-power
|
||||
// frequency a giant chapter's background rebuild stretches from ~40s to many
|
||||
// minutes, so the reader exits before it can finalize and the next open restarts
|
||||
// it from page 0. Reverts to normal power behavior the moment the build finishes.
|
||||
bool skipLoopDelay() override { return section && section->isBuilding(); }
|
||||
// it from page 0. Reverts to normal power behavior the moment the build finishes,
|
||||
// and while the build is heap-paused (no work is happening, so spinning at full
|
||||
// speed would only burn battery; the paused gate still retries every loop pass).
|
||||
bool skipLoopDelay() override { return section && section->isBuilding() && !buildHeapPaused; }
|
||||
bool isReaderActivity() const override { return true; }
|
||||
ScreenshotInfo getScreenshotInfo() const override;
|
||||
CrossPointPosition getCurrentPosition() const;
|
||||
|
||||
@@ -205,6 +205,14 @@ void CrossPointWebServer::begin() {
|
||||
udpActive = udp.begin(LOCAL_UDP_PORT);
|
||||
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
|
||||
|
||||
// All request handlers run on the task that calls handleClient(). Register
|
||||
// that task before any handler can call esp_task_wdt_reset().
|
||||
const esp_err_t watchdogResult = esp_task_wdt_add(nullptr);
|
||||
watchdogTaskRegistered = watchdogResult == ESP_OK;
|
||||
if (!watchdogTaskRegistered) {
|
||||
LOG_ERR("WEB", "Failed to register web server task with watchdog: %s", esp_err_to_name(watchdogResult));
|
||||
}
|
||||
|
||||
running = true;
|
||||
|
||||
LOG_DBG("WEB", "Web server started on port %d", port);
|
||||
@@ -234,6 +242,10 @@ void CrossPointWebServer::abortWsUpload(const char* tag) {
|
||||
void CrossPointWebServer::stop() {
|
||||
if (!running || !server) {
|
||||
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
|
||||
if (watchdogTaskRegistered) {
|
||||
esp_task_wdt_delete(nullptr);
|
||||
watchdogTaskRegistered = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,6 +286,11 @@ void CrossPointWebServer::stop() {
|
||||
LOG_DBG("WEB", "Web server stopped and deleted");
|
||||
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
if (watchdogTaskRegistered) {
|
||||
esp_task_wdt_delete(nullptr);
|
||||
watchdogTaskRegistered = false;
|
||||
}
|
||||
|
||||
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
|
||||
// later in the file and will be cleared when they go out of scope or on next upload
|
||||
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -72,6 +72,7 @@ class CrossPointWebServer {
|
||||
std::unique_ptr<WebServer> server = nullptr;
|
||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||
bool running = false;
|
||||
bool watchdogTaskRegistered = false;
|
||||
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||
uint16_t port = 80;
|
||||
uint16_t wsPort = 81; // WebSocket port
|
||||
|
||||
Reference in New Issue
Block a user