From c273e250f2c664607914bf35f2f14942356361ad Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 11 May 2026 22:27:13 +0200 Subject: [PATCH] Implement sparse css cache plus dynamic banding for AA --- lib/Epub/Epub.cpp | 16 +- lib/Epub/Epub/Section.cpp | 35 + lib/Epub/Epub/css/CssParser.cpp | 664 ++++++++++++++----- lib/Epub/Epub/css/CssParser.h | 58 +- lib/GfxRenderer/GfxRenderer.cpp | 104 ++- lib/GfxRenderer/GfxRenderer.h | 8 +- src/activities/reader/EpubReaderActivity.cpp | 75 ++- 7 files changed, 758 insertions(+), 202 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index f98b02eb..2bc43a3c 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -454,6 +454,11 @@ void Epub::parseCssFiles() const { } // No cache yet - parse CSS files + if (!cssParser->beginCacheCompile()) { + LOG_ERR("EBP", "Failed to start CSS compile pipeline"); + return; + } + for (const auto& cssPath : cssFiles) { LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str()); @@ -496,16 +501,17 @@ void Epub::parseCssFiles() const { Storage.remove(tmpCssPath.c_str()); continue; } - cssParser->loadFromStream(tempCssFile); + if (!cssParser->appendCompiledFromStream(tempCssFile)) { + LOG_ERR("EBP", "Failed to compile CSS file: %s", cssPath.c_str()); + } tempCssFile.close(); Storage.remove(tmpCssPath.c_str()); } - // Save to cache for next time - if (!cssParser->saveToCache()) { - LOG_ERR("EBP", "Failed to save CSS rules to cache"); + // Finalize compact cache for next time. + if (!cssParser->endCacheCompile()) { + LOG_ERR("EBP", "Failed to finalize CSS rules cache"); } - cssParser->clear(); LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index d1d4d8b6..3fe1fa8f 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include @@ -46,6 +48,20 @@ namespace { constexpr uint32_t FNV_PRIME = 0x01000193; // 16777619 constexpr uint32_t FNV_OFFSET_BASIS = 0x811C9DC5; // 2166136261 +// On constrained targets, loading the CSS rules map before chapter parsing can +// consume a large share of available heap and increase parse truncation risk. +// Allow compile-time override for tuning. +#ifndef SCT_EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES +#define SCT_EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES (96 * 1024) +#endif + +#ifndef SCT_EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES +#define SCT_EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES (56 * 1024) +#endif + +constexpr uint32_t EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES = SCT_EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES; +constexpr uint32_t EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES = SCT_EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES; + uint32_t fnv1a(const uint8_t* data, size_t length) { uint32_t hash = FNV_OFFSET_BASIS; for (size_t i = 0; i < length; ++i) { @@ -357,6 +373,21 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const bool bionicReadingEnabled, const uint8_t imageRendering, const std::function& progressFn) { + if (embeddedStyle) { + 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 < EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES || contigHeap < EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES) { + LOG_INF("SCT", + "Low heap for embedded CSS (free=%lu contig=%lu, need free>=%lu contig>=%lu); " + "building no-CSS section cache", + freeHeap, contigHeap, static_cast(EMBEDDED_STYLE_MIN_FREE_HEAP_BYTES), + static_cast(EMBEDDED_STYLE_MIN_CONTIG_HEAP_BYTES)); + return createSectionFile(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight, hyphenationEnabled, false, bionicReadingEnabled, imageRendering, + progressFn); + } + } + uint32_t propertyHash = calculatePropertyHash(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, hyphenationEnabled, embeddedStyle, bionicReadingEnabled, imageRendering); @@ -401,6 +432,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c if (!cssParser->loadFromCache()) { LOG_ERR("SCT", "Failed to load CSS from cache"); } + cssParser->resetResolveStats(); } } @@ -440,6 +472,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c const bool streamOk = epub->readItemContentsToStream(localPath, visitor, 1024); const bool finalizeOk = visitor.finalize(); const bool parserStreamOk = visitor.streamSucceeded(); + if (cssParser) { + cssParser->logResolveStats(localPath.c_str()); + } const bool parseComplete = streamOk && finalizeOk && parserStreamOk; bool success = parseComplete; const bool hasParsedPages = pageCount > 0; diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 33d2841c..a10c5deb 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -45,10 +45,32 @@ constexpr size_t MAX_RULES = 1500; // If below this threshold, we skip CSS to avoid display artifacts. constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024; +// In-memory CSS rule cache sizing for disk-backed lookup mode. +// Keeps memory bounded on large books while retaining hot selectors. +#ifndef CSS_HOT_RULE_CACHE_SIZE +#define CSS_HOT_RULE_CACHE_SIZE 128 +#endif + +#ifndef CSS_NEGATIVE_CACHE_SIZE +#define CSS_NEGATIVE_CACHE_SIZE 256 +#endif + +constexpr size_t HOT_RULE_CACHE_SIZE = CSS_HOT_RULE_CACHE_SIZE; +constexpr size_t NEGATIVE_CACHE_SIZE = CSS_NEGATIVE_CACHE_SIZE; + // Maximum length for a single selector string // Prevents parsing of extremely long or malformed selectors constexpr size_t MAX_SELECTOR_LENGTH = 256; +constexpr size_t CSS_LENGTH_FIELD_COUNT = 11; +constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t); +constexpr size_t CSS_FIXED_STYLE_BYTES = + 4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t); + +// Cache file name (version is CssParser::CSS_CACHE_VERSION) +constexpr char rulesCache[] = "/css_rules.cache"; +constexpr char compileTempRulesCache[] = "/css_rules.compile.tmp"; + // Check if character is CSS whitespace bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } @@ -445,12 +467,40 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons } // Skip if this would exceed the rule limit - if (rulesBySelector_.size() >= MAX_RULES) { + const size_t ruleCount = compileModeActive_ ? compileSelectorOffsets_.size() : rulesBySelector_.size(); + if (ruleCount >= MAX_RULES) { LOG_DBG("CSS", "Reached max rules limit, stopping selector processing"); return; } - // Store or merge with existing + if (compileModeActive_) { + if (!compileTempFile_) { + compileModeFailed_ = true; + continue; + } + + CssStyle merged = style; + auto existingOffsetIt = compileSelectorOffsets_.find(key); + if (existingOffsetIt != compileSelectorOffsets_.end()) { + CssStyle existing; + FsFile tempRead; + if (Storage.openFileForRead("CSS", compileTempPath_, tempRead) && tempRead.seek(existingOffsetIt->second) && + readCssStylePayload(tempRead, existing)) { + existing.applyOver(merged); + merged = existing; + } + if (tempRead) { + tempRead.close(); + } + } + + const uint32_t styleOffset = compileTempFile_.position(); + writeCssStylePayload(compileTempFile_, merged); + compileSelectorOffsets_[key] = styleOffset; + continue; + } + + // Store or merge with existing (non-compile mode) auto it = rulesBySelector_.find(key); if (it != rulesBySelector_.end()) { it->second.applyOver(style); @@ -608,25 +658,432 @@ bool CssParser::loadFromStream(FsFile& source) { return true; } +bool CssParser::beginCacheCompile() { + clear(); + compileTempPath_ = cachePath + compileTempRulesCache; + Storage.remove(compileTempPath_.c_str()); + if (!Storage.openFileForWrite("CSS", compileTempPath_, compileTempFile_)) { + return false; + } + compileSelectorOffsets_.clear(); + compileModeActive_ = true; + compileModeFailed_ = false; + return true; +} + +bool CssParser::appendCompiledFromStream(FsFile& source) { + if (!compileModeActive_) { + return false; + } + if (!loadFromStream(source)) { + compileModeFailed_ = true; + return false; + } + return !compileModeFailed_; +} + +bool CssParser::endCacheCompile() { + if (!compileModeActive_) { + return false; + } + + compileModeActive_ = false; + compileTempFile_.close(); + + if (compileModeFailed_) { + Storage.remove(compileTempPath_.c_str()); + compileSelectorOffsets_.clear(); + return false; + } + + FsFile outFile; + if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, outFile)) { + Storage.remove(compileTempPath_.c_str()); + compileSelectorOffsets_.clear(); + return false; + } + + outFile.write(CssParser::CSS_CACHE_VERSION); + const auto ruleCount = static_cast(compileSelectorOffsets_.size()); + outFile.write(reinterpret_cast(&ruleCount), sizeof(ruleCount)); + + FsFile tempFile; + if (!Storage.openFileForRead("CSS", compileTempPath_, tempFile)) { + outFile.close(); + Storage.remove((cachePath + rulesCache).c_str()); + Storage.remove(compileTempPath_.c_str()); + compileSelectorOffsets_.clear(); + return false; + } + + std::array styleBytes{}; + for (const auto& it : compileSelectorOffsets_) { + const auto selectorLen = static_cast(it.first.size()); + outFile.write(reinterpret_cast(&selectorLen), sizeof(selectorLen)); + outFile.write(reinterpret_cast(it.first.data()), selectorLen); + + if (!tempFile.seek(it.second)) { + tempFile.close(); + outFile.close(); + Storage.remove((cachePath + rulesCache).c_str()); + Storage.remove(compileTempPath_.c_str()); + compileSelectorOffsets_.clear(); + return false; + } + if (tempFile.read(styleBytes.data(), styleBytes.size()) != static_cast(styleBytes.size())) { + tempFile.close(); + outFile.close(); + Storage.remove((cachePath + rulesCache).c_str()); + Storage.remove(compileTempPath_.c_str()); + compileSelectorOffsets_.clear(); + return false; + } + outFile.write(styleBytes.data(), styleBytes.size()); + } + + tempFile.close(); + outFile.close(); + Storage.remove(compileTempPath_.c_str()); + + compileSelectorOffsets_.clear(); + + rulesBySelector_.clear(); + hotRuleCache_.clear(); + hotRuleLru_.clear(); + negativeRuleCache_.clear(); + cacheRuleOffsets_.clear(); + cacheIndexLoaded_ = false; + cachedRuleCount_ = 0; + + return ensureCacheIndexLoaded(); +} + +bool CssParser::empty() const { return ruleCount() == 0; } + +size_t CssParser::ruleCount() const { + if (!rulesBySelector_.empty()) { + return rulesBySelector_.size(); + } + if (cacheIndexLoaded_) { + return cachedRuleCount_; + } + return 0; +} + +void CssParser::clear() { + if (compileTempFile_) { + compileTempFile_.close(); + } + rulesBySelector_.clear(); + cacheRuleOffsets_.clear(); + hotRuleCache_.clear(); + hotRuleLru_.clear(); + negativeRuleCache_.clear(); + cacheIndexLoaded_ = false; + cachedRuleCount_ = 0; + resolveStats_ = {}; + compileModeActive_ = false; + compileModeFailed_ = false; + compileSelectorOffsets_.clear(); +} + +void CssParser::resetResolveStats() const { resolveStats_ = {}; } + +CssParser::ResolveStats CssParser::getResolveStats() const { return resolveStats_; } + +void CssParser::logResolveStats(const char* context) const { + const auto s = getResolveStats(); + LOG_DBG("CSS", + "resolve stats[%s]: calls=%lu lowHeapSkips=%lu mapHits=%lu hotHits=%lu diskHits=%lu misses=%lu " + "negativeHits=%lu hotSize=%u indexSize=%u", + context ? context : "n/a", s.resolveCalls, s.lowHeapSkips, s.mapHits, s.hotHits, s.diskHits, s.misses, + s.negativeHits, static_cast(hotRuleCache_.size()), static_cast(cachedRuleCount_)); +} + +bool CssParser::readCssStylePayload(FsFile& file, CssStyle& style) { + uint8_t enumVal; + if (file.read(&enumVal, 1) != 1) { + return false; + } + style.textAlign = static_cast(enumVal); + + if (file.read(&enumVal, 1) != 1) { + return false; + } + style.fontStyle = static_cast(enumVal); + + if (file.read(&enumVal, 1) != 1) { + return false; + } + style.fontWeight = static_cast(enumVal); + + if (file.read(&enumVal, 1) != 1) { + return false; + } + style.textDecoration = static_cast(enumVal); + + auto readLength = [&file](CssLength& len) -> bool { + if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) { + return false; + } + uint8_t unitVal; + if (file.read(&unitVal, 1) != 1) { + return false; + } + len.unit = static_cast(unitVal); + return true; + }; + + if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || + !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || + !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) || + !readLength(style.imageHeight) || !readLength(style.imageWidth)) { + return false; + } + + uint8_t displayVal; + if (file.read(&displayVal, 1) != 1) { + return false; + } + style.display = static_cast(displayVal); + + uint16_t definedBits = 0; + if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { + return false; + } + style.defined.textAlign = (definedBits & 1 << 0) != 0; + style.defined.fontStyle = (definedBits & 1 << 1) != 0; + style.defined.fontWeight = (definedBits & 1 << 2) != 0; + style.defined.textDecoration = (definedBits & 1 << 3) != 0; + style.defined.textIndent = (definedBits & 1 << 4) != 0; + style.defined.marginTop = (definedBits & 1 << 5) != 0; + style.defined.marginBottom = (definedBits & 1 << 6) != 0; + style.defined.marginLeft = (definedBits & 1 << 7) != 0; + style.defined.marginRight = (definedBits & 1 << 8) != 0; + style.defined.paddingTop = (definedBits & 1 << 9) != 0; + style.defined.paddingBottom = (definedBits & 1 << 10) != 0; + style.defined.paddingLeft = (definedBits & 1 << 11) != 0; + style.defined.paddingRight = (definedBits & 1 << 12) != 0; + style.defined.imageHeight = (definedBits & 1 << 13) != 0; + style.defined.imageWidth = (definedBits & 1 << 14) != 0; + style.defined.display = (definedBits & 1 << 15) != 0; + return true; +} + +void CssParser::writeCssStylePayload(FsFile& file, const CssStyle& style) { + file.write(static_cast(style.textAlign)); + file.write(static_cast(style.fontStyle)); + file.write(static_cast(style.fontWeight)); + file.write(static_cast(style.textDecoration)); + + auto writeLength = [&file](const CssLength& len) { + file.write(reinterpret_cast(&len.value), sizeof(len.value)); + file.write(static_cast(len.unit)); + }; + + writeLength(style.textIndent); + writeLength(style.marginTop); + writeLength(style.marginBottom); + writeLength(style.marginLeft); + writeLength(style.marginRight); + writeLength(style.paddingTop); + writeLength(style.paddingBottom); + writeLength(style.paddingLeft); + writeLength(style.paddingRight); + writeLength(style.imageHeight); + writeLength(style.imageWidth); + file.write(static_cast(style.display)); + + uint16_t definedBits = 0; + if (style.defined.textAlign) definedBits |= 1 << 0; + if (style.defined.fontStyle) definedBits |= 1 << 1; + if (style.defined.fontWeight) definedBits |= 1 << 2; + if (style.defined.textDecoration) definedBits |= 1 << 3; + if (style.defined.textIndent) definedBits |= 1 << 4; + if (style.defined.marginTop) definedBits |= 1 << 5; + if (style.defined.marginBottom) definedBits |= 1 << 6; + if (style.defined.marginLeft) definedBits |= 1 << 7; + if (style.defined.marginRight) definedBits |= 1 << 8; + if (style.defined.paddingTop) definedBits |= 1 << 9; + if (style.defined.paddingBottom) definedBits |= 1 << 10; + if (style.defined.paddingLeft) definedBits |= 1 << 11; + if (style.defined.paddingRight) definedBits |= 1 << 12; + if (style.defined.imageHeight) definedBits |= 1 << 13; + if (style.defined.imageWidth) definedBits |= 1 << 14; + if (style.defined.display) definedBits |= 1 << 15; + file.write(reinterpret_cast(&definedBits), sizeof(definedBits)); +} + +void CssParser::touchHotRule(const std::string& selector) const { + auto it = hotRuleCache_.find(selector); + if (it == hotRuleCache_.end()) { + return; + } + hotRuleLru_.erase(it->second.second); + hotRuleLru_.push_front(selector); + it->second.second = hotRuleLru_.begin(); +} + +void CssParser::cacheHotRule(const std::string& selector, const CssStyle& style) const { + auto it = hotRuleCache_.find(selector); + if (it != hotRuleCache_.end()) { + it->second.first = style; + touchHotRule(selector); + return; + } + + hotRuleLru_.push_front(selector); + hotRuleCache_.emplace(selector, std::make_pair(style, hotRuleLru_.begin())); + if (hotRuleCache_.size() > HOT_RULE_CACHE_SIZE) { + const std::string& evictKey = hotRuleLru_.back(); + hotRuleCache_.erase(evictKey); + hotRuleLru_.pop_back(); + } +} + +bool CssParser::readRuleFromDiskAtOffset(const uint32_t styleOffset, CssStyle& outStyle) const { + FsFile file; + if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) { + return false; + } + if (!file.seek(styleOffset)) { + file.close(); + return false; + } + const bool ok = readCssStylePayload(file, outStyle); + file.close(); + return ok; +} + +bool CssParser::lookupRule(const std::string& selector, CssStyle& outStyle) const { + auto mapIt = rulesBySelector_.find(selector); + if (mapIt != rulesBySelector_.end()) { + outStyle = mapIt->second; + resolveStats_.mapHits++; + return true; + } + + auto hotIt = hotRuleCache_.find(selector); + if (hotIt != hotRuleCache_.end()) { + outStyle = hotIt->second.first; + touchHotRule(selector); + resolveStats_.hotHits++; + return true; + } + + if (negativeRuleCache_.find(selector) != negativeRuleCache_.end()) { + resolveStats_.negativeHits++; + return false; + } + + if (!ensureCacheIndexLoaded()) { + return false; + } + + const auto offsetIt = cacheRuleOffsets_.find(selector); + if (offsetIt == cacheRuleOffsets_.end()) { + if (negativeRuleCache_.size() >= NEGATIVE_CACHE_SIZE) { + negativeRuleCache_.clear(); + } + negativeRuleCache_.insert(selector); + return false; + } + + if (!readRuleFromDiskAtOffset(offsetIt->second, outStyle)) { + return false; + } + + cacheHotRule(selector, outStyle); + resolveStats_.diskHits++; + return true; +} + +bool CssParser::ensureCacheIndexLoaded() const { + if (cacheIndexLoaded_) { + return true; + } + + FsFile file; + if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) { + return false; + } + + uint8_t version = 0; + if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) { + file.close(); + Storage.remove((cachePath + rulesCache).c_str()); + return false; + } + + uint16_t ruleCount = 0; + if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount) || ruleCount > MAX_RULES) { + file.close(); + return false; + } + + cacheRuleOffsets_.clear(); + cacheRuleOffsets_.reserve(ruleCount); + hotRuleCache_.clear(); + hotRuleLru_.clear(); + negativeRuleCache_.clear(); + + for (uint16_t i = 0; i < ruleCount; ++i) { + uint16_t selectorLen = 0; + if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen) || selectorLen == 0 || + selectorLen > MAX_SELECTOR_LENGTH) { + file.close(); + cacheRuleOffsets_.clear(); + return false; + } + + std::string selector; + selector.resize(selectorLen); + if (file.read(&selector[0], selectorLen) != selectorLen) { + file.close(); + cacheRuleOffsets_.clear(); + return false; + } + + const uint32_t styleOffset = file.position(); + cacheRuleOffsets_[std::move(selector)] = styleOffset; + if (!file.seek(styleOffset + CSS_FIXED_STYLE_BYTES)) { + file.close(); + cacheRuleOffsets_.clear(); + return false; + } + } + + cachedRuleCount_ = cacheRuleOffsets_.size(); + cacheIndexLoaded_ = true; + file.close(); + LOG_DBG("CSS", "Loaded CSS index: %u selectors (hot cache size=%u)", static_cast(cachedRuleCount_), + static_cast(HOT_RULE_CACHE_SIZE)); + return true; +} + // Style resolution CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const { static bool lowHeapWarningLogged = false; + resolveStats_.resolveCalls++; if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) { if (!lowHeapWarningLogged) { lowHeapWarningLogged = true; LOG_DBG("CSS", "Warning: low heap (%u bytes) below MIN_FREE_HEAP_FOR_CSS (%u), returning empty style", ESP.getFreeHeap(), static_cast(MIN_FREE_HEAP_FOR_CSS)); } + resolveStats_.lowHeapSkips++; return CssStyle{}; } CssStyle result; const std::string tag = normalized(tagName); // 1. Apply element-level style (lowest priority) - const auto tagIt = rulesBySelector_.find(tag); - if (tagIt != rulesBySelector_.end()) { - result.applyOver(tagIt->second); + { + CssStyle tagStyle; + if (lookupRule(tag, tagStyle)) { + result.applyOver(tagStyle); + } } // TODO: Support combinations of classes (e.g. style on .class1.class2) @@ -637,9 +1094,9 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& for (const auto& cls : classes) { std::string classKey = "." + normalized(cls); - auto classIt = rulesBySelector_.find(classKey); - if (classIt != rulesBySelector_.end()) { - result.applyOver(classIt->second); + CssStyle classStyle; + if (lookupRule(classKey, classStyle)) { + result.applyOver(classStyle); } } @@ -648,13 +1105,17 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& for (const auto& cls : classes) { std::string combinedKey = tag + "." + normalized(cls); - auto combinedIt = rulesBySelector_.find(combinedKey); - if (combinedIt != rulesBySelector_.end()) { - result.applyOver(combinedIt->second); + CssStyle combinedStyle; + if (lookupRule(combinedKey, combinedStyle)) { + result.applyOver(combinedStyle); } } } + if (!result.defined.anySet()) { + resolveStats_.misses++; + } + return result; } @@ -664,9 +1125,6 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par // Cache serialization -// Cache file name (version is CssParser::CSS_CACHE_VERSION) -constexpr char rulesCache[] = "/css_rules.cache"; - bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); } void CssParser::deleteCache() const { @@ -754,175 +1212,19 @@ bool CssParser::loadFromCache() { return false; } - FsFile file; - if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) { + // Drop parse-time in-memory rules, then initialize on-disk selector index. + rulesBySelector_.clear(); + hotRuleCache_.clear(); + hotRuleLru_.clear(); + negativeRuleCache_.clear(); + cacheRuleOffsets_.clear(); + cacheIndexLoaded_ = false; + cachedRuleCount_ = 0; + + if (!ensureCacheIndexLoaded()) { return false; } - // Clear existing rules - clear(); - - // Read and verify version - uint8_t version = 0; - if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) { - LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version, - CssParser::CSS_CACHE_VERSION); - file.close(); - Storage.remove((cachePath + rulesCache).c_str()); - return false; - } - - // Read rule count - uint16_t ruleCount = 0; - if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) { - file.close(); - return false; - } - - if (ruleCount > MAX_RULES) { - LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES); - rulesBySelector_.clear(); - file.close(); - return false; - } - - auto hasRemainingBytes = [&file](const size_t neededBytes) -> bool { - return static_cast(file.available()) >= neededBytes; - }; - - constexpr size_t CSS_LENGTH_FIELD_COUNT = 11; - constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t); - constexpr size_t CSS_FIXED_STYLE_BYTES = - 4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t); - - // Read each rule - for (uint16_t i = 0; i < ruleCount; ++i) { - // Read selector string - uint16_t selectorLen = 0; - if (!hasRemainingBytes(sizeof(selectorLen))) { - rulesBySelector_.clear(); - file.close(); - return false; - } - if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { - rulesBySelector_.clear(); - file.close(); - return false; - } - - if (selectorLen == 0 || selectorLen > MAX_SELECTOR_LENGTH || !hasRemainingBytes(selectorLen)) { - LOG_DBG("CSS", "Invalid selector length in cache: %u", selectorLen); - rulesBySelector_.clear(); - file.close(); - return false; - } - - std::string selector; - selector.resize(selectorLen); - if (file.read(&selector[0], selectorLen) != selectorLen) { - rulesBySelector_.clear(); - file.close(); - return false; - } - - if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) { - LOG_DBG("CSS", "Truncated CSS cache while reading style payload"); - rulesBySelector_.clear(); - file.close(); - return false; - } - - // Read CssStyle fields - CssStyle style; - uint8_t enumVal; - - if (file.read(&enumVal, 1) != 1) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.textAlign = static_cast(enumVal); - - if (file.read(&enumVal, 1) != 1) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.fontStyle = static_cast(enumVal); - - if (file.read(&enumVal, 1) != 1) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.fontWeight = static_cast(enumVal); - - if (file.read(&enumVal, 1) != 1) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.textDecoration = static_cast(enumVal); - - // Read CssLength fields - auto readLength = [&file](CssLength& len) -> bool { - if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) { - return false; - } - uint8_t unitVal; - if (file.read(&unitVal, 1) != 1) { - return false; - } - len.unit = static_cast(unitVal); - return true; - }; - - if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || - !readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || - !readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) || - !readLength(style.imageHeight) || !readLength(style.imageWidth)) { - rulesBySelector_.clear(); - file.close(); - return false; - } - - // Read display value - uint8_t displayVal; - if (file.read(&displayVal, 1) != 1) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.display = static_cast(displayVal); - - // Read defined flags - uint16_t definedBits = 0; - if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { - rulesBySelector_.clear(); - file.close(); - return false; - } - style.defined.textAlign = (definedBits & 1 << 0) != 0; - style.defined.fontStyle = (definedBits & 1 << 1) != 0; - style.defined.fontWeight = (definedBits & 1 << 2) != 0; - style.defined.textDecoration = (definedBits & 1 << 3) != 0; - style.defined.textIndent = (definedBits & 1 << 4) != 0; - style.defined.marginTop = (definedBits & 1 << 5) != 0; - style.defined.marginBottom = (definedBits & 1 << 6) != 0; - style.defined.marginLeft = (definedBits & 1 << 7) != 0; - style.defined.marginRight = (definedBits & 1 << 8) != 0; - style.defined.paddingTop = (definedBits & 1 << 9) != 0; - style.defined.paddingBottom = (definedBits & 1 << 10) != 0; - style.defined.paddingLeft = (definedBits & 1 << 11) != 0; - style.defined.paddingRight = (definedBits & 1 << 12) != 0; - style.defined.imageHeight = (definedBits & 1 << 13) != 0; - style.defined.imageWidth = (definedBits & 1 << 14) != 0; - style.defined.display = (definedBits & 1 << 15) != 0; - - rulesBySelector_[selector] = style; - } - - LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount); - file.close(); + LOG_DBG("CSS", "Loaded %u rules from cache index", static_cast(cachedRuleCount_)); return true; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 69bc3ec2..ffb9f27a 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -2,8 +2,10 @@ #include +#include #include #include +#include #include #include @@ -30,6 +32,16 @@ */ class CssParser { public: + struct ResolveStats { + uint32_t resolveCalls = 0; + uint32_t lowHeapSkips = 0; + uint32_t mapHits = 0; + uint32_t hotHits = 0; + uint32_t diskHits = 0; + uint32_t misses = 0; + uint32_t negativeHits = 0; + }; + // Bump when CSS cache format or rules change; section caches are invalidated when this changes static constexpr uint8_t CSS_CACHE_VERSION = 4; @@ -68,17 +80,17 @@ class CssParser { /** * Check if any rules have been loaded */ - [[nodiscard]] bool empty() const { return rulesBySelector_.empty(); } + [[nodiscard]] bool empty() const; /** * Get count of loaded rule sets */ - [[nodiscard]] size_t ruleCount() const { return rulesBySelector_.size(); } + [[nodiscard]] size_t ruleCount() const; /** * Clear all loaded rules */ - void clear() { rulesBySelector_.clear(); } + void clear(); /** * Check if CSS rules cache file exists @@ -103,12 +115,43 @@ class CssParser { */ bool loadFromCache(); + // Low-memory CSS compilation pipeline: + // - beginCacheCompile(): starts streaming compile mode + // - appendCompiledFromStream(): parses one stylesheet stream into compile staging + // - endCacheCompile(): finalizes cache file from staged records + bool beginCacheCompile(); + bool appendCompiledFromStream(FsFile& source); + bool endCacheCompile(); + + // CSS lookup telemetry helpers for tuning memory/caching behavior on-device. + void resetResolveStats() const; + [[nodiscard]] ResolveStats getResolveStats() const; + void logResolveStats(const char* context) const; + private: // Storage: maps normalized selector -> style properties std::unordered_map rulesBySelector_; std::string cachePath; + // Disk-backed CSS dictionary index: selector -> byte offset for serialized CssStyle payload. + // Built from cache file once, then styles are loaded on demand into hotRuleCache_. + mutable bool cacheIndexLoaded_ = false; + mutable size_t cachedRuleCount_ = 0; + mutable std::unordered_map cacheRuleOffsets_; + + // Bounded hot cache of most recently used rules. + mutable std::list hotRuleLru_; + mutable std::unordered_map::iterator>> hotRuleCache_; + mutable std::unordered_set negativeRuleCache_; + mutable ResolveStats resolveStats_; + + bool compileModeActive_ = false; + bool compileModeFailed_ = false; + std::string compileTempPath_; + FsFile compileTempFile_; + std::unordered_map compileSelectorOffsets_; + // Internal parsing helpers void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style); static CssStyle parseDeclarations(const std::string& declBlock); @@ -129,4 +172,13 @@ class CssParser { static void normalizedInto(const std::string& s, std::string& out); static std::vector splitOnChar(const std::string& s, char delimiter); static std::vector splitWhitespace(const std::string& s); + + // On-demand rule loading helpers + bool ensureCacheIndexLoaded() const; + bool lookupRule(const std::string& selector, CssStyle& outStyle) const; + bool readRuleFromDiskAtOffset(uint32_t styleOffset, CssStyle& outStyle) const; + static bool readCssStylePayload(FsFile& file, CssStyle& style); + static void writeCssStylePayload(FsFile& file, const CssStyle& style); + void touchHotRule(const std::string& selector) const; + void cacheHotRule(const std::string& selector, const CssStyle& style) const; }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 5697fc57..ba94ec7f 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -69,6 +69,9 @@ void GfxRenderer::begin() { panelHeight = display.getDisplayHeight(); panelWidthBytes = display.getDisplayWidthBytes(); frameBufferSize = display.getBufferSize(); + bwSnapshotRowStart = 0; + bwSnapshotRowEnd = 0; + bwSnapshotSizeBytes = 0; bwBufferChunkSize = BW_BUFFER_CHUNK_SIZE; bwBufferChunks.assign((frameBufferSize + bwBufferChunkSize - 1) / bwBufferChunkSize, nullptr); } @@ -2143,9 +2146,75 @@ void GfxRenderer::freeBwBufferChunks() { * Uses chunked allocation to avoid needing 48KB of contiguous memory. * Returns true if buffer was stored successfully, false if allocation failed. */ -bool GfxRenderer::storeBwBuffer() { +bool GfxRenderer::storeBwBuffer() { return storeBwBufferRect(0, 0, getScreenWidth(), getScreenHeight()); } + +bool GfxRenderer::storeBwBufferRect(const int x, const int y, const int width, const int height) { + if (width <= 0 || height <= 0) { + LOG_ERR("GFX", "!! BW buffer store rect invalid: x=%d y=%d w=%d h=%d", x, y, width, height); + return false; + } + + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + if (screenWidth <= 0 || screenHeight <= 0 || panelWidthBytes == 0 || panelHeight == 0 || !frameBuffer) { + LOG_ERR("GFX", "!! BW buffer store unavailable (screen=%dx%d panelHeight=%u rowBytes=%u fb=%p)", screenWidth, + screenHeight, panelHeight, panelWidthBytes, frameBuffer); + return false; + } + + const int clampedX0 = std::max(0, x); + const int clampedY0 = std::max(0, y); + const int clampedX1 = std::min(screenWidth - 1, x + width - 1); + const int clampedY1 = std::min(screenHeight - 1, y + height - 1); + if (clampedX0 > clampedX1 || clampedY0 > clampedY1) { + LOG_ERR("GFX", "!! BW buffer store rect outside screen: x=%d y=%d w=%d h=%d", x, y, width, height); + return false; + } + + int rowStart = 0; + int rowEnd = 0; + switch (getOrientation()) { + case LandscapeCounterClockwise: + rowStart = clampedY0; + rowEnd = clampedY1; + break; + case LandscapeClockwise: + rowStart = static_cast(panelHeight) - 1 - clampedY1; + rowEnd = static_cast(panelHeight) - 1 - clampedY0; + break; + case Portrait: + rowStart = static_cast(panelHeight) - 1 - clampedX1; + rowEnd = static_cast(panelHeight) - 1 - clampedX0; + break; + case PortraitInverted: + rowStart = clampedX0; + rowEnd = clampedX1; + break; + } + + rowStart = std::max(0, rowStart); + rowEnd = std::min(static_cast(panelHeight) - 1, rowEnd); + if (rowStart > rowEnd) { + LOG_ERR("GFX", "!! BW buffer store row-band invalid after orientation mapping: rows=%d..%d", rowStart, rowEnd); + return false; + } + + const size_t rows = static_cast(rowEnd - rowStart + 1); + const size_t snapshotSizeBytes = rows * panelWidthBytes; + const size_t snapshotBaseOffset = static_cast(rowStart) * panelWidthBytes; + if (snapshotSizeBytes == 0 || snapshotBaseOffset + snapshotSizeBytes > frameBufferSize) { + LOG_ERR("GFX", "!! BW buffer store row-band out of bounds: base=%zu size=%zu frame=%u", snapshotBaseOffset, + snapshotSizeBytes, frameBufferSize); + return false; + } + + freeBwBufferChunks(); + bwSnapshotRowStart = static_cast(rowStart); + bwSnapshotRowEnd = static_cast(rowEnd); + bwSnapshotSizeBytes = snapshotSizeBytes; + auto attemptStore = [&](size_t chunkSize) { - bwBufferChunks.assign((frameBufferSize + chunkSize - 1) / chunkSize, nullptr); + bwBufferChunks.assign((bwSnapshotSizeBytes + chunkSize - 1) / chunkSize, nullptr); for (size_t i = 0; i < bwBufferChunks.size(); i++) { if (bwBufferChunks[i]) { LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i); @@ -2154,7 +2223,7 @@ bool GfxRenderer::storeBwBuffer() { } const size_t offset = i * chunkSize; - const size_t allocSize = std::min(chunkSize, static_cast(frameBufferSize - offset)); + const size_t allocSize = std::min(chunkSize, bwSnapshotSizeBytes - offset); bwBufferChunks[i] = static_cast(malloc(allocSize)); if (!bwBufferChunks[i]) { @@ -2166,10 +2235,11 @@ bool GfxRenderer::storeBwBuffer() { return false; } - memcpy(bwBufferChunks[i], frameBuffer + offset, allocSize); + memcpy(bwBufferChunks[i], frameBuffer + snapshotBaseOffset + offset, allocSize); } bwBufferChunkSize = chunkSize; - LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", bwBufferChunks.size(), chunkSize); + LOG_DBG("GFX", "Stored BW buffer rows [%u..%u] (%zu bytes) in %zu chunks (%zu bytes each)", bwSnapshotRowStart, + bwSnapshotRowEnd, bwSnapshotSizeBytes, bwBufferChunks.size(), chunkSize); return true; }; @@ -2199,6 +2269,9 @@ bool GfxRenderer::storeBwBuffer() { } LOG_ERR("GFX", "!! BW buffer storage failed after retrying smaller chunk sizes"); + bwSnapshotSizeBytes = 0; + bwSnapshotRowStart = 0; + bwSnapshotRowEnd = 0; return false; } @@ -2208,6 +2281,13 @@ bool GfxRenderer::storeBwBuffer() { * Uses chunked restoration to match chunked storage. */ void GfxRenderer::restoreBwBuffer() { + if (bwSnapshotSizeBytes == 0) { + display.cleanupGrayscaleBuffers(frameBuffer); + freeBwBufferChunks(); + LOG_ERR("GFX", "BW restore skipped: no stored snapshot metadata; cleaned grayscale buffers only"); + return; + } + // Check if all chunks are allocated bool missingChunks = false; for (const auto& bwBufferChunk : bwBufferChunks) { @@ -2223,20 +2303,28 @@ void GfxRenderer::restoreBwBuffer() { // allocations that can later starve TLS handshakes. display.cleanupGrayscaleBuffers(frameBuffer); freeBwBufferChunks(); + bwSnapshotSizeBytes = 0; + bwSnapshotRowStart = 0; + bwSnapshotRowEnd = 0; LOG_ERR("GFX", "BW restore skipped due to missing chunks; cleaned grayscale buffers only"); return; } + const size_t snapshotBaseOffset = static_cast(bwSnapshotRowStart) * panelWidthBytes; for (size_t i = 0; i < bwBufferChunks.size(); i++) { const size_t offset = i * bwBufferChunkSize; - const size_t chunkSize = std::min(bwBufferChunkSize, static_cast(frameBufferSize - offset)); - memcpy(frameBuffer + offset, bwBufferChunks[i], chunkSize); + const size_t chunkSize = std::min(bwBufferChunkSize, bwSnapshotSizeBytes - offset); + memcpy(frameBuffer + snapshotBaseOffset + offset, bwBufferChunks[i], chunkSize); } display.cleanupGrayscaleBuffers(frameBuffer); freeBwBufferChunks(); - LOG_DBG("GFX", "Restored and freed BW buffer chunks"); + LOG_DBG("GFX", "Restored BW buffer rows [%u..%u] (%zu bytes) and freed BW chunks", bwSnapshotRowStart, + bwSnapshotRowEnd, bwSnapshotSizeBytes); + bwSnapshotSizeBytes = 0; + bwSnapshotRowStart = 0; + bwSnapshotRowEnd = 0; } /** diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 0a277dfa..87f02e05 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -55,6 +55,9 @@ class GfxRenderer { uint16_t panelHeight = 0; // set in begin() uint16_t panelWidthBytes = 0; // set in begin() uint32_t frameBufferSize = 0; // set in begin() + uint16_t bwSnapshotRowStart = 0; + uint16_t bwSnapshotRowEnd = 0; + size_t bwSnapshotSizeBytes = 0; size_t bwBufferChunkSize = BW_BUFFER_CHUNK_SIZE; std::vector bwBufferChunks; std::map fontMap; @@ -206,8 +209,9 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); // Restore and free the stored buffer + bool storeBwBuffer(); // Returns true if buffer was stored successfully + bool storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Font helpers diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b27b2769..c75cb534 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -75,11 +75,11 @@ constexpr uint32_t AA_RECOVERY_CONTIG_HEAP_BYTES = CP_AA_RECOVERY_CONTIG_HEAP_BY // 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) +#define CP_BW_SNAPSHOT_MIN_FREE_HEAP_BYTES (64 * 1024) #endif #ifndef CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES -#define CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES (52 * 1024) +#define CP_BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES (24 * 1024) #endif constexpr uint32_t BW_SNAPSHOT_MIN_FREE_HEAP_BYTES = CP_BW_SNAPSHOT_MIN_FREE_HEAP_BYTES; @@ -98,6 +98,62 @@ void logReaderMemSnapshot(const char* stage) { inline void logReaderMemSnapshot(const char*) {} #endif +bool computePageDynamicYBand(const Page& page, const GfxRenderer& renderer, const int fontId, const int viewportHeight, + int* outTop, int* outBottom) { + if (viewportHeight <= 0 || !outTop || !outBottom) { + return false; + } + + bool hasRange = false; + int minY = viewportHeight; + int maxY = -1; + const int lineHeight = std::max(1, renderer.getLineHeight(fontId)); + + for (const auto& el : page.elements) { + if (!el) continue; + + int elementTop = el->yPos; + int elementBottom = el->yPos; + switch (el->getTag()) { + case TAG_PageLine: + elementBottom = el->yPos + lineHeight; + break; + case TAG_PageImage: { + const auto& img = static_cast(*el); + elementBottom = el->yPos + img.getImageBlock().getHeight(); + break; + } + case TAG_PageTable: { + const auto& table = static_cast(*el); + elementBottom = el->yPos + table.getTotalHeight(); + break; + } + default: + continue; + } + + minY = std::min(minY, elementTop); + maxY = std::max(maxY, elementBottom); + hasRange = true; + } + + if (!hasRange) { + return false; + } + + constexpr int BAND_PAD_PX = 2; + minY = std::max(0, minY - BAND_PAD_PX); + maxY = std::min(viewportHeight, maxY + BAND_PAD_PX); + + if (minY >= maxY) { + return false; + } + + *outTop = minY; + *outBottom = maxY; + return true; +} + // Computes the [0..100] EPUB progress percent. Returns 0 when pageCount is unknown (sync/bookmark // pre-render writes), in which case the next saveProgress() will overwrite progress.bin with the // real value before the user can leave the reader. @@ -1708,7 +1764,20 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or logReaderMemSnapshot("bw_store_begin"); bool bwBufferStored = false; if (shouldAttemptBwSnapshot) { - bwBufferStored = renderer.storeBwBuffer(); + const int contentLeft = orientedMarginLeft; + const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); + const int contentBottom = std::max(contentTop, renderer.getScreenHeight() - orientedMarginBottom); + + int bandTop = 0; + int bandBottom = std::max(0, contentBottom - contentTop); + if (!computePageDynamicYBand(*page, renderer, getEffectiveReaderFontId(), bandBottom, &bandTop, &bandBottom)) { + bandTop = 0; + bandBottom = std::max(0, contentBottom - contentTop); + } + + const int snapshotTop = contentTop + bandTop; + const int snapshotHeight = std::max(0, bandBottom - bandTop); + bwBufferStored = renderer.storeBwBufferRect(contentLeft, snapshotTop, contentRight - contentLeft, snapshotHeight); } 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),