diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ed734aa4..d1d4d8b6 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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(0)); // Placeholder for LUT offset (patched later) serialization::writePod(file, static_cast(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(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; diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index cc7c6b0e..e2b840d1 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -17,6 +17,7 @@ class Section { std::string filePath; FsFile file; std::vector 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& progressFn = nullptr); std::unique_ptr loadPageFromSectionFile(); + bool isTruncatedCache() const { return truncatedCache; } // Given a page in this section, return the TOC index for that page. int getTocIndexForPage(int page) const; diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 36bb5d49..a8c09c03 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -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((width + 3) / 4) * static_cast(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(freeHeap), + static_cast(minFreeForCaching), static_cast(cacheBytes)); + return false; + } + + if (maxAlloc < minMaxAllocForCaching) { + LOG_DBG("JPG", "Skipping cache: max alloc %u < %u (cache %u bytes)", static_cast(maxAlloc), + static_cast(minMaxAllocForCaching), static_cast(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(ESP.getFreeHeap()), static_cast(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) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index f6c3f2cd..e0abb252 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -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(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(viewportWidth - horizontalInset) : viewportWidth; + if (!ensureHeapForTextLayout("paragraph layout")) { + return; + } + currentTextBlock->layoutAndExtractLines( renderer, fontId, effectiveWidth, [this](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord, diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 08647baf..9f47ff27 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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 inlineStyleCache_; void updateEffectiveInlineStyle(); + bool ensureHeapForTextLayout(const char* phase); void startNewTextBlock(const BlockStyle& blockStyle); void flushPartWordBuffer(); void makePages(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 28ca5ad9..b27b2769 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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, 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, 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, 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(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES), + static_cast(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, 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, 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()) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 6a182f89..ecae6077 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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;