Reduce memory footprint

This commit is contained in:
jpirnay
2026-05-11 21:50:44 +02:00
parent 5b3f1ad200
commit fb16db2b48
7 changed files with 373 additions and 32 deletions
+68 -15
View File
@@ -12,7 +12,7 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 26;
constexpr uint8_t SECTION_FILE_VERSION = 27;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
sizeof(int) + // fontId
sizeof(float) + // lineCompression
@@ -20,6 +20,7 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
sizeof(uint8_t) + // paragraphAlignment
sizeof(uint16_t) + // viewportWidth
sizeof(uint16_t) + // viewportHeight
sizeof(bool) + // parseComplete
sizeof(uint16_t) + // pageCount (stored as 16-bit in header)
sizeof(bool) + // hyphenationEnabled
sizeof(bool) + // embeddedStyle
@@ -195,9 +196,9 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
}
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(bionicReadingEnabled) + sizeof(imageRendering) +
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
sizeof(viewportHeight) + sizeof(bool) + sizeof(pageCount) +
sizeof(hyphenationEnabled) + sizeof(embeddedStyle) + sizeof(bionicReadingEnabled) +
sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -210,6 +211,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, embeddedStyle);
serialization::writePod(file, bionicReadingEnabled);
serialization::writePod(file, imageRendering);
serialization::writePod(file, false); // Placeholder for parseComplete (patched later)
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
@@ -220,13 +222,30 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const bool bionicReadingEnabled, const uint8_t imageRendering) {
truncatedCache = false;
uint32_t propertyHash =
calculatePropertyHash(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering);
filePath = getSectionFilePath(propertyHash);
bool usingEmbeddedStyleFallback = false;
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
// Fallback: allow loading a no-CSS cache variant when embedded CSS is enabled.
if (embeddedStyle) {
const uint32_t fallbackHash =
calculatePropertyHash(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, false, bionicReadingEnabled, imageRendering);
const std::string fallbackPath = getSectionFilePath(fallbackHash);
if (Storage.openFileForRead("SCT", fallbackPath, file)) {
filePath = fallbackPath;
usingEmbeddedStyleFallback = true;
LOG_ERR("SCT", "Using no-CSS section cache fallback: %s", filePath.c_str());
} else {
return false;
}
} else {
return false;
}
}
// Match parameters
@@ -248,6 +267,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
bool fileEmbeddedStyle;
bool fileBionicReadingEnabled;
uint8_t fileImageRendering;
bool fileParseComplete;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
@@ -258,16 +278,21 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileBionicReadingEnabled);
serialization::readPod(file, fileImageRendering);
serialization::readPod(file, fileParseComplete);
const bool embeddedStyleMatches =
(embeddedStyle == fileEmbeddedStyle) || (usingEmbeddedStyleFallback && !fileEmbeddedStyle);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
hyphenationEnabled != fileHyphenationEnabled || !embeddedStyleMatches ||
bionicReadingEnabled != fileBionicReadingEnabled || imageRendering != fileImageRendering) {
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache(); // closes file before removal
return false;
}
truncatedCache = !fileParseComplete;
}
serialization::readPod(file, pageCount);
@@ -311,6 +336,7 @@ bool Section::clearCache() {
lut.clear();
pageCount = 0;
currentPage = 0;
truncatedCache = false;
if (!Storage.exists(filePath.c_str())) {
LOG_DBG("SCT", "Cache does not exist, no action needed");
@@ -413,7 +439,10 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const uint32_t phaseParseStart = millis();
const bool streamOk = epub->readItemContentsToStream(localPath, visitor, 1024);
const bool finalizeOk = visitor.finalize();
bool success = streamOk && finalizeOk && visitor.streamSucceeded();
const bool parserStreamOk = visitor.streamSucceeded();
const bool parseComplete = streamOk && finalizeOk && parserStreamOk;
bool success = parseComplete;
const bool hasParsedPages = pageCount > 0;
const uint32_t parseMs = millis() - phaseParseStart;
// streamMs is no longer a separate phase (SD-write of temp file is gone); keep the
// log breakdown stable by reporting it as 0.
@@ -421,13 +450,35 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const uint32_t phaseFinalizeStart = millis();
if (!success) {
LOG_ERR("SCT", "Failed to parse XML and build pages (stream=%d finalize=%d)", streamOk ? 1 : 0, finalizeOk ? 1 : 0);
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
// If parsing fails mid-stream due low memory but some pages were already serialized,
// keep the partial section cache so the chapter remains readable instead of failing hard.
if (hasParsedPages) {
LOG_ERR("SCT", "Parse incomplete; keeping partial section cache with %u pages (stream=%d finalize=%d parser=%d)",
pageCount, streamOk ? 1 : 0, finalizeOk ? 1 : 0, parserStreamOk ? 1 : 0);
success = true;
} else if (embeddedStyle) {
LOG_ERR("SCT",
"Parse failed with embedded CSS enabled; retrying section creation with embeddedStyle=0 "
"(stream=%d finalize=%d)",
streamOk ? 1 : 0, finalizeOk ? 1 : 0);
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return createSectionFile(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, false, bionicReadingEnabled, imageRendering,
progressFn);
} else {
LOG_ERR("SCT", "Failed to parse XML and build pages (stream=%d finalize=%d)", streamOk ? 1 : 0,
finalizeOk ? 1 : 0);
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return false;
}
return false;
}
const uint32_t fileSize = static_cast<uint32_t>(inflatedSize);
@@ -477,8 +528,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, entry.listItemIndex);
}
// Patch header with final pageCount, lutOffset, anchorMapOffset, and paragraphLutOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3 - sizeof(pageCount));
// Patch header with final parseComplete/pageCount and offsets.
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3 - sizeof(pageCount) - sizeof(bool));
serialization::writePod(file, parseComplete);
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
@@ -499,6 +551,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
LOG_ERR("SCT", "Failed to open section file for reading after creation");
return false;
}
truncatedCache = !parseComplete;
this->lut = std::move(lut);
const uint32_t finalizeMs = millis() - phaseFinalizeStart;
const uint32_t totalMs = millis() - phaseTotalStart;
+2
View File
@@ -17,6 +17,7 @@ class Section {
std::string filePath;
FsFile file;
std::vector<uint32_t> lut; // Cached page byte-offsets; loaded once, avoids per-page LUT seek
bool truncatedCache = false;
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
@@ -68,6 +69,7 @@ class Section {
bool bionicReadingEnabled, uint8_t imageRendering,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
bool isTruncatedCache() const { return truncatedCache; }
// Given a page in this section, return the TOC index for that page.
int getTocIndexForPage(int page) const;
@@ -26,6 +26,7 @@ struct JpegContext {
const RenderConfig* config{nullptr};
int screenWidth{0};
int screenHeight{0};
ImageDitherMode effectiveDitherMode{ImageDitherMode::Bayer};
// Source dimensions after JPEGDEC's built-in scaling
int scaledSrcWidth{0};
@@ -103,7 +104,7 @@ uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int out
return quantizeGray4Level(gray);
}
switch (ctx.config->ditherMode) {
switch (ctx.effectiveDitherMode) {
case ImageDitherMode::Atkinson:
if (ctx.atkinsonDitherer) {
return ctx.atkinsonDitherer->processPixel(gray, localX);
@@ -179,6 +180,71 @@ int32_t jpegSeek(JPEGFILE* pFile, int32_t pos) {
constexpr size_t JPEG_DECODER_APPROX_SIZE = 20 * 1024;
constexpr size_t MIN_FREE_HEAP_FOR_JPEG = JPEG_DECODER_APPROX_SIZE + 16 * 1024;
// Optional memory-behavior knobs for embedded targets.
#ifndef JPEG_ENABLE_FIRST_RENDER_NO_CACHE
#define JPEG_ENABLE_FIRST_RENDER_NO_CACHE 1
#endif
#ifndef JPEG_CACHE_MIN_FREE_HEAP_MARGIN
#define JPEG_CACHE_MIN_FREE_HEAP_MARGIN (24 * 1024)
#endif
#ifndef JPEG_CACHE_MIN_MAX_ALLOC_MARGIN
#define JPEG_CACHE_MIN_MAX_ALLOC_MARGIN (20 * 1024)
#endif
#ifndef JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP
#define JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP (MIN_FREE_HEAP_FOR_JPEG + 8 * 1024)
#endif
#ifndef JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC
#define JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC (48 * 1024)
#endif
size_t jpegCacheBytes(int width, int height) {
return static_cast<size_t>((width + 3) / 4) * static_cast<size_t>(height);
}
bool shouldEnableJpegCache(const RenderConfig& config, int width, int height) {
if (config.cachePath.empty()) return false;
#if JPEG_ENABLE_FIRST_RENDER_NO_CACHE
if (!Storage.exists(config.cachePath.c_str())) {
LOG_DBG("JPG", "Skipping cache on first render (compile-time policy): %s", config.cachePath.c_str());
return false;
}
#endif
const size_t cacheBytes = jpegCacheBytes(width, height);
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxAlloc = ESP.getMaxAllocHeap();
const size_t minFreeForCaching = MIN_FREE_HEAP_FOR_JPEG + JPEG_CACHE_MIN_FREE_HEAP_MARGIN;
const size_t minMaxAllocForCaching = cacheBytes + JPEG_CACHE_MIN_MAX_ALLOC_MARGIN;
if (freeHeap < minFreeForCaching) {
LOG_DBG("JPG", "Skipping cache: free heap %u < %u (cache %u bytes)", static_cast<unsigned>(freeHeap),
static_cast<unsigned>(minFreeForCaching), static_cast<unsigned>(cacheBytes));
return false;
}
if (maxAlloc < minMaxAllocForCaching) {
LOG_DBG("JPG", "Skipping cache: max alloc %u < %u (cache %u bytes)", static_cast<unsigned>(maxAlloc),
static_cast<unsigned>(minMaxAllocForCaching), static_cast<unsigned>(cacheBytes));
return false;
}
return true;
}
bool shouldForceBayerDither(const RenderConfig& config) {
if (!config.useDithering) return false;
if (config.ditherMode == ImageDitherMode::Bayer) return false;
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxAlloc = ESP.getMaxAllocHeap();
return freeHeap < JPEG_DITHER_LOW_MEM_MIN_FREE_HEAP || maxAlloc < JPEG_DITHER_LOW_MEM_MIN_MAX_ALLOC;
}
bool readJpegDimensionsFromHeader(const std::string& imagePath, ImageDimensions& out) {
FsFile f;
if (!Storage.openFileForRead("JPG", imagePath, f)) {
@@ -526,6 +592,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
ctx.effectiveDitherMode = config.ditherMode;
int rc = jpeg->open(imagePath.c_str(), jpegOpen, jpegClose, jpegRead, jpegSeek, jpegDrawCallback);
if (rc != 1) {
@@ -605,7 +672,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
jpeg->setUserPointer(&ctx);
// Allocate cache buffer using final output dimensions
ctx.caching = !config.cachePath.empty();
ctx.caching = shouldEnableJpegCache(config, destWidth, destHeight);
if (ctx.caching) {
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
@@ -613,6 +680,12 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
}
if (shouldForceBayerDither(config)) {
LOG_DBG("JPG", "Low-memory mode: forcing Bayer dithering (%u free, %u max alloc)",
static_cast<unsigned>(ESP.getFreeHeap()), static_cast<unsigned>(ESP.getMaxAllocHeap()));
ctx.effectiveDitherMode = ImageDitherMode::Bayer;
}
// See PngToFramebufferConverter for rationale: BW-only display needs a 1-bit
// dither so mid-grays don't collapse to black under DirectPixelWriter's `< 3` rule.
if (config.monochromeOutput) {
@@ -624,7 +697,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
if (config.useDithering && !ctx.atkinson1BitDitherer) {
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
switch (config.ditherMode) {
switch (ctx.effectiveDitherMode) {
case ImageDitherMode::Atkinson:
ctx.atkinsonDitherer.reset(new (std::nothrow) AtkinsonDitherer(destWidth));
if (!ctx.atkinsonDitherer) {
@@ -6,6 +6,7 @@
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
#include <esp_heap_caps.h>
#include <expat.h>
#include <algorithm>
@@ -30,11 +31,42 @@ constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
constexpr size_t MIN_SIZE_FOR_POPUP = 15 * 1024;
constexpr size_t SIZE_FOR_PROGRESS_HEARTBEAT = 30 * 1024;
constexpr size_t SIZE_FOR_PROGRESS_FINE = 80 * 1024;
constexpr size_t MIN_FREE_HEAP_FOR_INDEXING_POPUP = 32 * 1024;
constexpr size_t MIN_CONTIG_HEAP_FOR_INDEXING_POPUP = 12 * 1024;
// Parser progress popup/ticks trigger full-screen refreshes that can temporarily
// collapse heap during section cache builds on constrained targets. Keep disabled
// by default; enable only when debugging parse progress behavior.
#ifndef EHP_ENABLE_PARSE_PROGRESS_UI
#define EHP_ENABLE_PARSE_PROGRESS_UI 0
#endif
constexpr size_t PARSE_BUFFER_SIZE = 1024;
constexpr size_t IMAGE_EXTRACT_CHUNK_SIZE = 1024;
constexpr size_t MIN_FREE_HEAP_FOR_IMAGE_EXTRACT = 48 * 1024;
constexpr size_t MIN_MAX_ALLOC_FOR_IMAGE_EXTRACT = 36 * 1024;
#ifndef EHP_TEXT_LAYOUT_SOFT_MIN_FREE_HEAP
#define EHP_TEXT_LAYOUT_SOFT_MIN_FREE_HEAP (18 * 1024)
#endif
#ifndef EHP_TEXT_LAYOUT_SOFT_MIN_MAX_ALLOC
#define EHP_TEXT_LAYOUT_SOFT_MIN_MAX_ALLOC (12 * 1024)
#endif
#ifndef EHP_TEXT_LAYOUT_HARD_MIN_FREE_HEAP
#define EHP_TEXT_LAYOUT_HARD_MIN_FREE_HEAP (9 * 1024)
#endif
#ifndef EHP_TEXT_LAYOUT_HARD_MIN_MAX_ALLOC
#define EHP_TEXT_LAYOUT_HARD_MIN_MAX_ALLOC (6 * 1024)
#endif
constexpr size_t MIN_FREE_HEAP_FOR_TEXT_LAYOUT = EHP_TEXT_LAYOUT_SOFT_MIN_FREE_HEAP;
constexpr size_t MIN_MAX_ALLOC_FOR_TEXT_LAYOUT = EHP_TEXT_LAYOUT_SOFT_MIN_MAX_ALLOC;
constexpr size_t MIN_FREE_HEAP_FOR_TEXT_LAYOUT_HARD = EHP_TEXT_LAYOUT_HARD_MIN_FREE_HEAP;
constexpr size_t MIN_MAX_ALLOC_FOR_TEXT_LAYOUT_HARD = EHP_TEXT_LAYOUT_HARD_MIN_MAX_ALLOC;
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote", "pre"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@@ -198,6 +230,34 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
}
}
bool ChapterHtmlSlimParser::ensureHeapForTextLayout(const char* phase) {
if (streamFailed) {
return false;
}
const uint32_t freeHeap = ESP.getFreeHeap();
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
if (freeHeap >= MIN_FREE_HEAP_FOR_TEXT_LAYOUT || maxAllocHeap >= MIN_MAX_ALLOC_FOR_TEXT_LAYOUT) {
return true;
}
// Soft low-memory zone: keep parsing in degraded mode and only hard-abort when
// both free and contiguous heap fall to critical levels.
if (freeHeap >= MIN_FREE_HEAP_FOR_TEXT_LAYOUT_HARD && maxAllocHeap >= MIN_MAX_ALLOC_FOR_TEXT_LAYOUT_HARD) {
lowMemoryImageFallback = true;
LOG_DBG("EHP", "Low heap (%u free, %u max alloc) before %s; continuing in degraded mode", freeHeap, maxAllocHeap,
phase);
return true;
}
LOG_ERR("EHP", "Low heap (%u free, %u max alloc), aborting parse before %s", freeHeap, maxAllocHeap, phase);
streamFailed = true;
if (activeParser) {
XML_StopParser(activeParser, XML_FALSE);
}
return false;
}
// flush the contents of partWordBuffer to currentTextBlock
void ChapterHtmlSlimParser::flushPartWordBuffer() {
// Determine font style from depth-based tracking and CSS effective style
@@ -229,6 +289,11 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
if (currentTextBlock->size() > 96) {
if (!ensureHeapForTextLayout("long-block split")) {
partWordBufferIndex = 0;
nextWordContinues = false;
return;
}
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
const int horizontalInset = currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth =
@@ -1572,8 +1637,25 @@ bool ChapterHtmlSlimParser::setup(const size_t totalInflatedSize) {
progressStepPercent = 50;
}
const uint32_t popupFreeHeap = ESP.getFreeHeap();
const uint32_t popupContigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
#if EHP_ENABLE_PARSE_PROGRESS_UI
progressUiEnabled =
popupFreeHeap >= MIN_FREE_HEAP_FOR_INDEXING_POPUP && popupContigHeap >= MIN_CONTIG_HEAP_FOR_INDEXING_POPUP;
if (!progressUiEnabled) {
LOG_DBG("EHP", "Skipping indexing popup due to low heap (free=%u contig=%u)", popupFreeHeap, popupContigHeap);
// When popup is disabled, also disable mid-parse ticks.
progressStepPercent = 0;
}
#else
progressUiEnabled = false;
progressStepPercent = 0;
LOG_DBG("EHP", "Skipping parser progress popup/ticks (EHP_ENABLE_PARSE_PROGRESS_UI=0, free=%u contig=%u)",
popupFreeHeap, popupContigHeap);
#endif
// Show initial progress popup for files above threshold.
if (progressFn && totalStreamSize >= MIN_SIZE_FOR_POPUP) {
if (progressFn && progressUiEnabled && totalStreamSize >= MIN_SIZE_FOR_POPUP) {
progressFn(0);
}
return true;
@@ -1614,7 +1696,7 @@ size_t ChapterHtmlSlimParser::write(const uint8_t* buffer, const size_t size) {
// Report progress at the granularity chosen up-front (see progressStepPercent).
// Skip the 100% callback — the page render that follows immediately replaces the popup,
// so the final tick is wasted work.
if (progressFn && progressStepPercent > 0 && totalStreamSize > 0) {
if (progressFn && progressUiEnabled && progressStepPercent > 0 && totalStreamSize > 0) {
const int progress = static_cast<int>(bytesStreamed * 100 / totalStreamSize);
if (progress < 100 && progress / progressStepPercent > lastReportedProgress / progressStepPercent) {
lastReportedProgress = progress;
@@ -1736,6 +1818,10 @@ void ChapterHtmlSlimParser::makePages() {
const uint16_t effectiveWidth =
(horizontalInset < viewportWidth) ? static_cast<uint16_t>(viewportWidth - horizontalInset) : viewportWidth;
if (!ensureHeapForTextLayout("paragraph layout")) {
return;
}
currentTextBlock->layoutAndExtractLines(
renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock, const bool lineEndsWithHyphenatedWord,
@@ -137,6 +137,7 @@ class ChapterHtmlSlimParser final : public Print {
size_t bytesStreamed = 0;
int lastReportedProgress = -1;
int progressStepPercent = 0;
bool progressUiEnabled = true;
bool streamFailed = false;
uint32_t streamStartTimeMs = 0;
@@ -155,6 +156,7 @@ class ChapterHtmlSlimParser final : public Print {
std::unordered_map<std::string, CssStyle> inlineStyleCache_;
void updateEffectiveInlineStyle();
bool ensureHeapForTextLayout(const char* phase);
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
void makePages();
+131 -12
View File
@@ -44,6 +44,50 @@ constexpr unsigned long skipChapterMs = 700;
// pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_LABELS[] = {1, 1, 3, 6, 12};
// Silent next-chapter indexing should only run when heap is healthy enough,
// otherwise it tends to produce heavily truncated fallback caches.
#ifndef CP_SILENT_INDEX_MIN_FREE_HEAP_BYTES
#define CP_SILENT_INDEX_MIN_FREE_HEAP_BYTES (64 * 1024)
#endif
#ifndef CP_SILENT_INDEX_MIN_CONTIG_HEAP_BYTES
#define CP_SILENT_INDEX_MIN_CONTIG_HEAP_BYTES (24 * 1024)
#endif
constexpr uint32_t SILENT_INDEX_MIN_FREE_HEAP_BYTES = CP_SILENT_INDEX_MIN_FREE_HEAP_BYTES;
constexpr uint32_t SILENT_INDEX_MIN_CONTIG_HEAP_BYTES = CP_SILENT_INDEX_MIN_CONTIG_HEAP_BYTES;
// Hysteresis for auto AA recovery after low-memory BW snapshot failures.
// Override at build time via -D flags, for example:
// -DCP_AA_RECOVERY_FREE_HEAP_BYTES=30720
// -DCP_AA_RECOVERY_CONTIG_HEAP_BYTES=12288
#ifndef CP_AA_RECOVERY_FREE_HEAP_BYTES
#define CP_AA_RECOVERY_FREE_HEAP_BYTES (28 * 1024)
#endif
#ifndef CP_AA_RECOVERY_CONTIG_HEAP_BYTES
#define CP_AA_RECOVERY_CONTIG_HEAP_BYTES (12 * 1024)
#endif
constexpr uint32_t AA_RECOVERY_FREE_HEAP_BYTES = CP_AA_RECOVERY_FREE_HEAP_BYTES;
constexpr uint32_t AA_RECOVERY_CONTIG_HEAP_BYTES = CP_AA_RECOVERY_CONTIG_HEAP_BYTES;
// Snapshotting BW buffer for grayscale restore can fragment heap heavily on tight
// pages. Skip attempting snapshot altogether below this safety window.
#ifndef CP_BW_SNAPSHOT_MIN_FREE_HEAP_BYTES
#define CP_BW_SNAPSHOT_MIN_FREE_HEAP_BYTES (72 * 1024)
#endif
#ifndef CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES
#define CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES (52 * 1024)
#endif
constexpr uint32_t BW_SNAPSHOT_MIN_FREE_HEAP_BYTES = CP_BW_SNAPSHOT_MIN_FREE_HEAP_BYTES;
constexpr uint32_t BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES = CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES;
constexpr uint8_t TRUNCATED_SECTION_HINT_RENDER_COUNT = 2;
constexpr const char* TRUNCATED_SECTION_HINT_LINE_1 = "Chapter may be truncated (low memory).";
constexpr const char* TRUNCATED_SECTION_HINT_LINE_2 = "Try: No embedded style | No images | AA Off";
#if DEBUG_MEMORY_CONSUMPTION
void logReaderMemSnapshot(const char* stage) {
const uint32_t freeHeap = esp_get_free_heap_size();
@@ -1300,6 +1344,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
lastRenderStats.textAntiAliasing = SETTINGS.textAntiAliasing;
lastRenderStats.freeHeapBefore = esp_get_free_heap_size();
lastRenderStats.largestFreeBlockBefore = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
showTruncatedSectionHintThisRender = false;
if (!section) {
if (currentSpineIndex < 0 || currentSpineIndex >= spineCount) {
@@ -1353,6 +1398,12 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
lastRenderStats.sectionLoadMs = millis() - sectionStart;
if (section->isTruncatedCache() && currentSpineIndex != lastWarnedTruncatedSpineIndex) {
lastWarnedTruncatedSpineIndex = currentSpineIndex;
truncatedSectionHintRendersRemaining = TRUNCATED_SECTION_HINT_RENDER_COUNT;
LOG_INF("ERS", "Section %d is truncated; showing mitigation hint", currentSpineIndex);
}
if (nextPageNumber == UINT16_MAX) {
section->currentPage = section->pageCount - 1;
} else {
@@ -1472,10 +1523,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
lastRenderStats.spineIndex = currentSpineIndex;
lastRenderStats.pageIndex = section->currentPage;
lastRenderStats.pageCount = section->pageCount;
showTruncatedSectionHintThisRender = truncatedSectionHintRendersRemaining > 0;
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
lastRenderStats.requestRenderMs = millis() - start;
if (truncatedSectionHintRendersRemaining > 0) {
truncatedSectionHintRendersRemaining--;
}
LOG_DBG("ERS", "Rendered page in %dms", lastRenderStats.requestRenderMs);
}
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
@@ -1495,6 +1550,13 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW
return;
}
const uint32_t freeHeap = esp_get_free_heap_size();
const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
if (freeHeap < SILENT_INDEX_MIN_FREE_HEAP_BYTES || contigHeap < SILENT_INDEX_MIN_CONTIG_HEAP_BYTES) {
LOG_DBG("ERS", "Skipping silent indexing due to low heap (free=%lu contig=%lu)", freeHeap, contigHeap);
return;
}
// Build the next chapter cache while the penultimate page is on screen.
if (section->currentPage != section->pageCount - 2) {
return;
@@ -1560,8 +1622,23 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
(int32_t)heapAfter - (int32_t)heapBefore);
logReaderMemSnapshot("prewarm_end");
const bool aaConfigured = SETTINGS.textAntiAliasing;
bool aaEnabledForThisRender = aaConfigured;
if (aaConfigured && antiAliasingSuspendedLowMemory) {
const uint32_t freeHeapNow = esp_get_free_heap_size();
const uint32_t contigHeapNow = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
if (freeHeapNow >= AA_RECOVERY_FREE_HEAP_BYTES && contigHeapNow >= AA_RECOVERY_CONTIG_HEAP_BYTES) {
antiAliasingSuspendedLowMemory = false;
LOG_INF("ERS", "Re-enabling text anti-aliasing after heap recovery (free=%lu contig=%lu)", freeHeapNow,
contigHeapNow);
} else {
aaEnabledForThisRender = false;
}
}
lastRenderStats.textAntiAliasing = aaEnabledForThisRender;
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
bool imagePageWithAA = page->hasImages() && aaEnabledForThisRender;
bool forceHalfRefreshThisPage = pendingHalfRefreshAfterImagePage && SETTINGS.halfRefreshAfterImagePage;
pendingHalfRefreshAfterImagePage = false;
lastRenderStats.imagePageWithAA = imagePageWithAA;
@@ -1570,6 +1647,23 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
logReaderMemSnapshot("before_bw_render");
page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop);
renderStatusBar();
if (showTruncatedSectionHintThisRender) {
const int hintX = orientedMarginLeft + 4;
const int hintY1 = contentTop + 4;
const int hintY2 = hintY1 + 20;
const int maxWidth = std::max(0, renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight - 8);
// Clear a dedicated band so the hint stays readable over any page content.
const int boxX = hintX - 2;
const int boxY = hintY1 - 2;
const int boxW = maxWidth + 4;
const int boxH = 44;
renderer.fillRect(boxX, boxY, boxW, boxH, false);
renderer.drawRect(boxX, boxY, boxW, boxH, true);
const std::string line1 = renderer.truncatedText(UI_10_FONT_ID, TRUNCATED_SECTION_HINT_LINE_1, maxWidth);
const std::string line2 = renderer.truncatedText(UI_10_FONT_ID, TRUNCATED_SECTION_HINT_LINE_2, maxWidth);
renderer.drawText(UI_10_FONT_ID, hintX, hintY1, line1.c_str(), true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, hintX, hintY2, line2.c_str(), true);
}
fcm->logStats("bw_render");
const auto tBwRender = millis();
logReaderMemSnapshot("after_bw_render");
@@ -1604,11 +1698,33 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
const auto tDisplay = millis();
// Save bw buffer to reset buffer state after grayscale data sync
// Save bw buffer to reset buffer state after grayscale data sync.
// Pre-check heap to avoid entering the chunk-retry path when success is unlikely.
const uint32_t bwStoreFreeHeap = esp_get_free_heap_size();
const uint32_t bwStoreContigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
const bool shouldAttemptBwSnapshot =
bwStoreFreeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && bwStoreContigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES;
logReaderMemSnapshot("bw_store_begin");
renderer.storeBwBuffer();
bool bwBufferStored = false;
if (shouldAttemptBwSnapshot) {
bwBufferStored = renderer.storeBwBuffer();
} else {
LOG_INF("ERS", "Skipping BW snapshot precheck (free=%lu contig=%lu, need free>=%lu contig>=%lu)", bwStoreFreeHeap,
bwStoreContigHeap, static_cast<uint32_t>(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES),
static_cast<uint32_t>(BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES));
}
const auto tBwStore = millis();
logReaderMemSnapshot("bw_store_end");
if (!bwBufferStored) {
if (aaEnabledForThisRender && !antiAliasingSuspendedLowMemory) {
antiAliasingSuspendedLowMemory = true;
const uint32_t freeHeapNow = esp_get_free_heap_size();
const uint32_t contigHeapNow = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
LOG_INF("ERS", "Suspending text anti-aliasing due to low heap (free=%lu contig=%lu)", freeHeapNow, contigHeapNow);
}
LOG_INF("ERS", "Skipping grayscale/BW-restore for this page (insufficient heap for BW snapshot)");
}
if (page->hasImages() && getEffectiveImageRendering() != CrossPointSettings::IMAGES_SUPPRESS) {
pendingHalfRefreshAfterImagePage = true;
@@ -1616,7 +1732,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// grayscale rendering
// TODO: Only do this if font supports it
if (SETTINGS.textAntiAliasing) {
if (aaEnabledForThisRender && bwBufferStored) {
logReaderMemSnapshot("gray_lsb_begin");
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
@@ -1659,21 +1775,24 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
} else {
// restore the bw data
logReaderMemSnapshot("bw_restore_begin");
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
logReaderMemSnapshot("bw_restore_end");
uint32_t bwRestoreMs = 0;
if (bwBufferStored) {
// restore the bw data
logReaderMemSnapshot("bw_restore_begin");
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
logReaderMemSnapshot("bw_restore_end");
bwRestoreMs = tBwRestore - tBwStore;
}
const auto tEnd = millis();
lastRenderStats.usedGrayscale = false;
lastRenderStats.phases = {
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, 0, 0, 0, tBwRestore - tBwStore,
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, 0, 0, 0, bwRestoreMs,
tEnd - t0};
LOG_DBG("ERS",
"Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums bw_restore=%lums total=%lums",
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tBwRestore - tBwStore,
tEnd - t0);
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, bwRestoreMs, tEnd - t0);
}
if (const auto* cacheManager = renderer.getFontCacheManager()) {
@@ -40,6 +40,12 @@ class EpubReaderActivity final : public Activity {
unsigned long lastPageTurnTime = 0UL;
unsigned long pageTurnDuration = 0UL;
bool pendingHalfRefreshAfterImagePage = false;
// Temporary AA suspension when BW snapshot allocation fails under memory pressure.
// Automatically lifted once heap recovers above hysteresis thresholds.
bool antiAliasingSuspendedLowMemory = false;
bool showTruncatedSectionHintThisRender = false;
uint8_t truncatedSectionHintRendersRemaining = 0;
int lastWarnedTruncatedSpineIndex = -1;
struct RenderPhaseStats {
unsigned long prewarmMs = 0UL;
unsigned long bwRenderMs = 0UL;