From 029f71bf263ea961301c73606cfdcf9d66b1e353 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Wed, 10 Jun 2026 21:27:39 -0700 Subject: [PATCH 01/10] fix(prebake): atlas continuous-bitstream packing + missing-fontId pre-flight Two atlas-pipeline bugs caught when prebake'd glyph atlases shipped to the device produced unreadable rendering: 1. GlyphAtlas.h rowBytes/glyphBytes assumed byte-aligned-per-row but the on-device renderer (GfxRenderer::renderCharImpl) reads the bitmap as one continuous bitstream -- pixel index runs 0..(width*height-1) and the byte address is pixelPosition >> 3. Per-row alignment misaligned every row past the first for any glyph whose width is not a multiple of 8 (most glyphs in a proportional font), giving a "ghost stroke per character" rendering artifact. Switch the builder to continuous packing matching the renderer's blit loop. atlas size shrinks ~3% as a side effect. 2. tools/crumble-prebake/src/main.cpp silently produced "section N wrote 1 pages" for SD-font books when the user forgot --sd-font-path. Root cause: section settings declared an SD-font fontId, the renderer's fontMap did not contain it, every glyph metric (advanceX, advanceY, ascender) returned 0, addLineToPage's `currentPageNextY + 0 > viewportHeight` never fired, all chapter content stacked onto page 1 with zero-height lines. Add a pre-flight check at the top of prebakeSections: if s.fontId is not in renderer.getFontMap(), refuse the build with an error message naming the missing flag. Bump the call-site failure log so the CLI exits non-zero instead of swallowing the hard failure as a "skipped". --- lib/Epub/Epub/GlyphAtlas.h | 24 ++++++++++---- tools/crumble-prebake/src/main.cpp | 53 +++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/lib/Epub/Epub/GlyphAtlas.h b/lib/Epub/Epub/GlyphAtlas.h index 2e348776..c8035d1e 100644 --- a/lib/Epub/Epub/GlyphAtlas.h +++ b/lib/Epub/Epub/GlyphAtlas.h @@ -93,17 +93,29 @@ static_assert(sizeof(BlockHeader) == 12, "BlockHeader must be 12 bytes on the wi static_assert(sizeof(StyleHeader) == 12, "StyleHeader must be 12 bytes on the wire"); static_assert(sizeof(GlyphEntry) == 12, "GlyphEntry must be 12 bytes on the wire"); -// Helpers for packed bitmap addressing. The bitmap payload is row-major: -// for each glyph, height consecutive rows of (width + padBits) / 8 bytes -// each, where padBits rounds the row up to a byte boundary. This matches -// the SD-card font format used today, so the existing 1-bit blit code path -// in EpdFont can be reused unchanged. +// Helpers for packed bitmap addressing. The bitmap payload is a CONTINUOUS +// bitstream (no per-row alignment) so a single (y*width + x) pixel index +// addresses straight into it. This matches GfxRenderer::renderCharImpl's +// blit loop (see GfxRenderer.cpp, the "pixelPosition" variable: pixel index +// runs 0..(width*height-1) across rows, and the byte address is +// pixelPosition >> 3 for 1-bit / >> 2 for 2-bit). The FontDecompressor's +// compactSingleGlyph likewise produces continuous bitstream output -- so +// builtin fonts feed the same renderer with the same packing convention. +// Byte-aligning rows would silently misalign every row past the first for +// any glyph whose width isn't a multiple of 8 (for 1-bit) or 4 (for 2-bit). +// +// rowBytes() is retained only as a per-row helper for callers that walk +// individual rows; the canonical glyph size is glyphBytes(), which uses +// continuous packing. constexpr uint16_t rowBytes(uint8_t widthPx, uint8_t bitDepth) { return static_cast((widthPx * bitDepth + 7) / 8); } constexpr uint16_t glyphBytes(uint8_t widthPx, uint8_t heightPx, uint8_t bitDepth) { - return static_cast(rowBytes(widthPx, bitDepth)) * heightPx; + // Continuous packing: total bits = width * height * bitDepth, rounded up + // to the next whole byte for the glyph as a whole (not per-row). + return static_cast( + (static_cast(widthPx) * static_cast(heightPx) * bitDepth + 7) / 8); } } // namespace glyphatlas diff --git a/tools/crumble-prebake/src/main.cpp b/tools/crumble-prebake/src/main.cpp index 4eaeab9c..d6523ffe 100644 --- a/tools/crumble-prebake/src/main.cpp +++ b/tools/crumble-prebake/src/main.cpp @@ -872,6 +872,28 @@ int prebakeSections(const std::string& epubPath, const std::string& realCacheDir } LOG_INF("PRE", "section gen: %d spine entries to build", spineCount); + // Pre-flight: the section settings declare a fontId (a hash of family + size). + // The renderer needs that fontId in its fontMap so layout calls -- + // getLineHeight, getFontAscenderSize, getTextAdvanceX -- return real metrics. + // If the fontId is missing, every metric returns 0, addLineToPage's + // "currentPageNextY + lineHeight > viewportHeight" page-break check never + // fires, all chapter content lands on page 1, and the user sees "section N + // wrote 1 pages" for every section, then jumbled rendering on device. The + // common cause is forgetting --sd-font-path / --sd-font-family / --sd-font-size + // for an SD-card font book -- without those flags the SD font isn't registered + // in the renderer and its hashed fontId stays unknown. Fail loud rather than + // silently producing broken section caches. + if (renderer.getFontMap().find(s.fontId) == renderer.getFontMap().end()) { + LOG_ERR("PRE", + "fontId=%d declared in section settings is NOT registered in the renderer. " + "Layout metrics will all be 0 and every section would silently bake to 1 page. " + "If this book uses an SD-card .cpfont, pass --sd-font-path / --sd-font-family / " + "--sd-font-size so the host renderer can register the matching font. " + "Aborting section prebake for this book.", + s.fontId); + return -1; + } + int failures = 0; for (int spineIdx = 0; spineIdx < spineCount; ++spineIdx) { Section section(epub, spineIdx, renderer); @@ -1198,8 +1220,14 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { const uint32_t glyphIdx = iv.offset + (cp - iv.first); if (glyphIdx >= glyphCount) continue; const EpdGlyph g = glyphs[glyphIdx]; - const uint16_t outRowBytes = glyphatlas::rowBytes(g.width, glyphatlas::BIT_DEPTH_1); - const uint32_t outGlyphBytes = static_cast(outRowBytes) * g.height; + // Output is a CONTINUOUS 1-bit bitstream (no per-row byte alignment), + // matching GfxRenderer::renderCharImpl's blit loop which reads + // bitmap[pixelPosition >> 3] with pixelPosition running 0..(w*h-1) + // across rows. Byte-aligning each row would misalign every row past + // the first for any width not a multiple of 8 -- exactly the + // "ghost stroke per character" rendering artifact we hit on first + // device test of the atlas. + const uint32_t outGlyphBytes = glyphatlas::glyphBytes(g.width, g.height, glyphatlas::BIT_DEPTH_1); if (bitmapData.size() + outGlyphBytes > 0xFFFFu) { LOG_ERR("PRE", "atlas: bitmap payload would exceed 64 KB at cp U+%04X style %u; truncating", cp, s); break; // bitmapBytes field is uint16_t @@ -1208,9 +1236,12 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { bitmapData.resize(bitmapData.size() + outGlyphBytes, 0); const uint8_t* srcGlyph = srcBitmap + g.dataOffset; - // Per-pixel copy + threshold. Source format is per-glyph row-major - // packed at the font's bit depth; output is row-major 1-bit big - // endian within byte (matches the EpdFont blit path). + // Per-pixel copy + threshold. Source format is continuous bitstream + // at the font's bit depth (FontDecompressor::compactSingleGlyph + // already repacks byte-aligned hot-group data into continuous output, + // and SD-card mini bitmaps inherit that packing). Output is a + // continuous 1-bit bitstream, big-endian within byte (bit 7 = first + // pixel) -- same convention the renderer's else-branch uses. for (uint32_t y = 0; y < g.height; ++y) { for (uint32_t x = 0; x < g.width; ++x) { uint8_t srcPixel; @@ -1230,8 +1261,9 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { // (intentional trade for half the storage); for already-1-bit // sources it's identity. if (srcPixel != 0) { - const uint32_t outByteIdx = y * outRowBytes + (x / 8u); - const uint32_t outBitInByte = x % 8u; + const uint32_t outBit = y * g.width + x; + const uint32_t outByteIdx = outBit / 8u; + const uint32_t outBitInByte = outBit % 8u; bitmapData[bitmapOffset + outByteIdx] |= static_cast(1u << (7u - outBitInByte)); } } @@ -1801,7 +1833,12 @@ int main(int argc, char** argv) { } else if (sectionFails > 0) { LOG_INF("CLI", " sections PARTIAL: %d failed (%u ms)", sectionFails, dtSections); } else { - LOG_INF("CLI", " sections SKIPPED (Epub::load failed, %u ms)", dtSections); + // -1 from prebakeSections is the hard-failure code: Epub::load failed, + // shadow-copy failed, or the fontId pre-flight rejected the build (see + // the matching LOG_ERR upstream for the specific cause). Either way the + // book has no section cache. + LOG_ERR("CLI", " sections HARD FAIL (%u ms) -- see prior PRE log for cause", dtSections); + ++failures; } } } From 03fb432a28af6f4cf3d3afe78cc3e32acc23e3a1 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Wed, 10 Jun 2026 21:27:56 -0700 Subject: [PATCH 02/10] feat(reader): typography substitution + bold/italic regular-style fallback Three reader-rendering improvements to fix '?' glyphs that the SD-font glyph atlas could not cover: 1. Parser-level typography substitution (ChapterHtmlSlimParser). Smart quotes / dashes / ellipsis / bullets in the 0xE2 0x80 UTF-8 block get replaced with ASCII equivalents at parse time, so the stored page text -- and the prewarmed glyph atlas baked from it -- only contain ASCII codepoints fonts reliably ship. Without this step the renderer-level aliasCodepoint() had no atlas target to resolve to because the chapter never used the ASCII form directly. Same parser runs on host (prebake CLI) and device (live rebuild), keeping the substitution consistent. 2. Renderer aliasCodepoint() table expanded (EpdFontData.h). Adds smart-quote / dash / ellipsis / decoration (diamond / star / bullet) substitutions as a runtime safety net, useful when the parser-level substitution did not run (e.g. live UI strings, dictionary entries). 3. getFallbackCodepoint() cascade fix (EpdFontFamily.cpp). Two missing fallback steps previously short-circuited to REPLACEMENT_GLYPH: (a) when both cp and alias miss findGlyphData, give the SD font's miss handler a chance to lazy-load the alias target from the .cpfont full glyph table; (b) for non-regular styles, probe the regular chain so bold/italic text rendered against an atlas that only carries the REGULAR style (the prebake's prewarm default) falls back to the regular glyph instead of '?'. Chapter titles in bold were the dominant visible symptom -- this fixes them at the cost of losing the bold weight (text reads as regular). --- lib/EpdFont/EpdFontData.h | 82 ++++++++++++++++++- lib/EpdFont/EpdFontFamily.cpp | 35 +++++++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 81 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index d01c0281..81b4bdac 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -161,8 +161,88 @@ constexpr bool isReplacementFallback(uint32_t cp) { return cp == 0xFFFD; } constexpr bool isSpaceFallback(uint32_t cp) { return cp == 0x00A0 || cp == 0x1680 || (cp >= 0x2000 && cp <= 0x200A) || cp == 0x202F || cp == 0x205F || cp == 0x3000; } +// Typography fallback table (v4.3 task #28). When a font lacks a fancy +// punctuation codepoint, alias it to the nearest ASCII equivalent so the +// renderer's glyph lookup finds a real glyph instead of producing '?'. +// EpdFontFamily::getFallbackCodepoint runs this on every miss, AND a +// successful alias still has to be present in the font itself for the +// renderer to draw it -- but for the SD-font atlas / embedded-subset +// path the ASCII targets ('. - " ' , * `) appear in almost every section +// (they are the bread-and-butter glyphs of any prose chapter), so the +// alias closes the visible '?' gap in practice. Tried-and-failed +// substitutions still bottom out at REPLACEMENT_GLYPH per the existing +// getFallbackCodepoint flow. constexpr uint32_t aliasCodepoint(uint32_t cp) { - return cp == MODIFIER_LETTER_TURNED_COMMA ? LEFT_SINGLE_QUOTATION_MARK : cp; + switch (cp) { + // Pre-existing alias: the Hawaiian okina / Latin extended turned comma + // both look like a left single quote; keep mapping it to the typographic + // single quote so any font shipping the typographic glyph wins. The + // smart-quote branches below alias that target back to ASCII when the + // font lacks it. + case MODIFIER_LETTER_TURNED_COMMA: + return LEFT_SINGLE_QUOTATION_MARK; + + // Smart single quotes / similar -> ASCII apostrophe (U+0027) + case 0x2018: // LEFT SINGLE QUOTATION MARK + case 0x2019: // RIGHT SINGLE QUOTATION MARK + case 0x201A: // SINGLE LOW-9 QUOTATION MARK + case 0x201B: // SINGLE HIGH-REVERSED-9 QUOTATION MARK + case 0x2032: // PRIME + return 0x27; + + // Smart double quotes / similar -> ASCII quotation mark (U+0022) + case 0x201C: // LEFT DOUBLE QUOTATION MARK + case 0x201D: // RIGHT DOUBLE QUOTATION MARK + case 0x201E: // DOUBLE LOW-9 QUOTATION MARK + case 0x201F: // DOUBLE HIGH-REVERSED-9 QUOTATION MARK + case 0x2033: // DOUBLE PRIME + return 0x22; + + // Dashes (incl. non-breaking hyphen) -> ASCII hyphen-minus (U+002D). + // Loses semantic length distinction (em vs en) -- acceptable trade for + // not showing '?' in dialogue dashes and parentheticals. + case 0x2010: // HYPHEN + case 0x2011: // NON-BREAKING HYPHEN + case 0x2012: // FIGURE DASH + case 0x2013: // EN DASH + case 0x2014: // EM DASH + case 0x2015: // HORIZONTAL BAR + case 0x2212: // MINUS SIGN (math) + return 0x2D; + + // Bullets, geometric shapes, decoration glyphs -> ASCII asterisk + // (U+002A). Covers the scene-break decorator chars publishers use + // to separate scenes within a chapter (diamonds, stars, hollow + // shapes) -- ChareInk and similarly-trimmed SD fonts often ship + // without these specific geometric blocks. The renderer-level + // getFallbackCodepoint miss-handler path will try to load the '*' + // glyph on demand from the .cpfont's full intervals if the prewarmed + // mini set didn't include it. + case 0x2022: // BULLET + case 0x2023: // TRIANGULAR BULLET + case 0x25CF: // BLACK CIRCLE + case 0x25E6: // WHITE BULLET + case 0x25C6: // BLACK DIAMOND + case 0x25C7: // WHITE DIAMOND + case 0x25C8: // WHITE DIAMOND CONTAINING BLACK SMALL DIAMOND + case 0x25CA: // LOZENGE + case 0x2666: // BLACK DIAMOND SUIT + case 0x2662: // WHITE DIAMOND SUIT + case 0x2605: // BLACK STAR + case 0x2606: // WHITE STAR + case 0x2731: // HEAVY ASTERISK + case 0x2732: // OPEN CENTRE ASTERISK + case 0x2735: // EIGHT POINTED PINWHEEL STAR + return 0x2A; + + // Horizontal ellipsis -> ASCII period (U+002E). 1:1 alias loses the + // three-dot appearance; ASCII '.' is in every chapter so this beats '?'. + case 0x2026: + return 0x2E; + + default: + return cp; + } } inline uint16_t solidAdvanceX(const EpdFontData* data, const EpdGlyph* emGlyph) { diff --git a/lib/EpdFont/EpdFontFamily.cpp b/lib/EpdFont/EpdFontFamily.cpp index d0da8b57..c20cb29f 100644 --- a/lib/EpdFont/EpdFontFamily.cpp +++ b/lib/EpdFont/EpdFontFamily.cpp @@ -341,10 +341,43 @@ uint32_t EpdFontFamily::getFallbackCodepoint(const uint32_t cp, const Style styl if (findGlyphData(cp, style).glyph) return cp; const uint32_t aliasCp = syntheticGlyph::aliasCodepoint(cp); if (aliasCp != cp) { - return findGlyphData(aliasCp, style).glyph ? aliasCp : REPLACEMENT_GLYPH; + if (findGlyphData(aliasCp, style).glyph) return aliasCp; + // CrumBLE 4.4 task #28 follow-up: when findGlyphData misses on both + // cp and aliasCp, give the font's own miss handler a chance to load + // the alias target. The atlas / SD-font miniData only contain + // codepoints the chapter actually uses, so a chapter that has '◆' + // but no '*' anywhere will see findGlyphData('*') miss even though + // the underlying .cpfont ships an asterisk glyph. font->getGlyph + // walks the font's intervals AND (for SD-card fonts) calls the + // onGlyphMiss handler which lazy-loads from the .cpfont's full + // glyph table into the overflow ring buffer -- so a successful + // load here means the renderer will draw a real glyph instead of + // the synthetic replacement box. Zero cost when the font's intervals + // already excluded the alias target (binary-search miss = ~150ns). + const EpdFont* f = getFont(style); + if (f && f->getGlyph(aliasCp)) return aliasCp; + // CrumBLE 4.4 task #26: when bold/italic miss-handler also fails + // (font lacks that style entirely), check the regular-style chain. + // Mirrors getGlyphData's regular-fallback so drawText's pre-flight + // decision matches the actual draw-time outcome. + if (style != REGULAR && getGlyphData(aliasCp, REGULAR).glyph) return aliasCp; + return REPLACEMENT_GLYPH; } if (syntheticGlyph::isSpaceFallback(cp)) return cp; if (syntheticGlyph::isSolid(cp) || syntheticGlyph::isGreekFallback(cp)) return cp; + // CrumBLE 4.4 task #26: chapter titles + emphasis use BOLD / ITALIC. + // When the atlas only carries the REGULAR style (the prebake's prewarm + // only loads REGULAR by default) AND the SD font lacks the requested + // style's glyph (miss handler returns null because the .cpfont ships + // regular-only), getGlyphData's existing regular fallback (line ~276) + // would salvage the draw -- but only IF the renderer reaches the + // renderCharImpl path. Returning REPLACEMENT_GLYPH here short-circuits + // that path and forces the synthetic '?' box instead. Probe regular's + // chain for `cp`; if it has the glyph, return cp so drawText proceeds + // to renderCharImpl which calls getGlyphData and lets the regular + // fallback cascade run. The bold/italic visual style is lost but text + // stays readable -- preferable to "??? Two" for chapter headers. + if (style != REGULAR && getGlyphData(cp, REGULAR).glyph) return cp; return REPLACEMENT_GLYPH; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 87c24120..0e88f2b0 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1625,6 +1625,87 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char continue; } + // CrumBLE 4.4 task #28: typographic punctuation -> ASCII substitution. + // Applied at parse time so the stored page text and the prewarmed glyph + // working set are both ASCII for these codepoints. Without this the + // atlas / SD-font mini bitmap only contains whatever the chapter + // happened to use (typically only the smart-quote form, never the ASCII + // form), so the renderer-level aliasCodepoint() target ('. - " ' *) + // had no glyph in the atlas to alias to and we still rendered '?'. + // + // Same parser runs on host (prebake CLI) and device (live section + // rebuild), so the substitution is consistent between baked sections + // and any on-device rebuild path. Cost: lose semantic distinction + // between em-dash / en-dash / hyphen, ellipsis collapses to a single + // period, and smart quotes become straight ASCII. All acceptable + // trades vs visible '?' glyphs. + // + // The targeted three-byte sequences all start with 0xE2 0x80, covering + // the General Punctuation block (U+2010..U+2027). The third-byte + // switch picks the substitution; non-matching codepoints fall through + // unchanged (the FEFF check below + the generic byte-append at the + // end of the loop still see the original bytes). + if (i + 2 < len && static_cast(s[i]) == 0xE2 && static_cast(s[i + 1]) == 0x80) { + const uint8_t b3 = static_cast(s[i + 2]); + char ascii = 0; + switch (b3) { + case 0x98: // U+2018 LEFT SINGLE QUOTATION MARK + case 0x99: // U+2019 RIGHT SINGLE QUOTATION MARK + case 0x9A: // U+201A SINGLE LOW-9 QUOTATION MARK + case 0x9B: // U+201B SINGLE HIGH-REVERSED-9 QUOTATION MARK + ascii = '\''; + break; + case 0x9C: // U+201C LEFT DOUBLE QUOTATION MARK + case 0x9D: // U+201D RIGHT DOUBLE QUOTATION MARK + case 0x9E: // U+201E DOUBLE LOW-9 QUOTATION MARK + case 0x9F: // U+201F DOUBLE HIGH-REVERSED-9 QUOTATION MARK + ascii = '"'; + break; + case 0x90: // U+2010 HYPHEN + case 0x91: // U+2011 NON-BREAKING HYPHEN + case 0x92: // U+2012 FIGURE DASH + case 0x93: // U+2013 EN DASH + case 0x94: // U+2014 EM DASH + case 0x95: // U+2015 HORIZONTAL BAR + ascii = '-'; + break; + case 0xA2: // U+2022 BULLET + case 0xA3: // U+2023 TRIANGULAR BULLET + ascii = '*'; + break; + case 0xA6: // U+2026 HORIZONTAL ELLIPSIS + ascii = '.'; + break; + default: + break; + } + if (ascii != 0) { + // Buffer-overflow safety: mirror the byte-append path below so a + // word at MAX_WORD_SIZE flushes before we write the ASCII byte. + if (self->partWordBufferIndex >= MAX_WORD_SIZE) { + int safeLen = utf8SafeTruncateBuffer(self->partWordBuffer, self->partWordBufferIndex); + if (safeLen < self->partWordBufferIndex && safeLen > 0) { + int overflow = self->partWordBufferIndex - safeLen; + char saved[4]; + for (int j = 0; j < overflow; j++) { + saved[j] = self->partWordBuffer[safeLen + j]; + } + self->partWordBufferIndex = safeLen; + self->flushPartWordBuffer(); + for (int j = 0; j < overflow; j++) { + self->partWordBuffer[j] = saved[j]; + } + self->partWordBufferIndex = overflow; + } else { + self->flushPartWordBuffer(); + } + } + self->partWordBuffer[self->partWordBufferIndex++] = ascii; + i += 2; // loop's i++ handles the first byte; skip the other two + continue; + } + } + // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF const XML_Char FEFF_BYTE_1 = static_cast(0xEF); const XML_Char FEFF_BYTE_2 = static_cast(0xBB); From 1655473814250c85f1123b2654be75486586fee5 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Wed, 10 Jun 2026 21:28:09 -0700 Subject: [PATCH 03/10] fix(bt): first-connect retry-with-cooldown + NimBLE round 2 trim Two BT stability fixes addressing reliability of first quick-connect and post-connect MaxAlloc collapse: 1. BluetoothHIDManager always retries the initial connect with a fresh client after a ~300 ms cool-down. Previously the retry path only fired when hadExistingClient was true and ran instantly. User pattern was reliable: first connect attempt times out (controller still discovering / advertising and our scan window misses it), second one succeeds. Make that retry unconditional and add the short cool-down so RF state settles. 2. NimBLE config round 2 (platformio.ini): - ATT_PREFERRED_MTU: 64 -> 23 (BT spec minimum; HID does not benefit) - MSYS1_BLOCK_COUNT: 4 -> 3 - TRANSPORT_ACL_FROM_LL_COUNT: 4 -> 2 - TRANSPORT_EVT_COUNT: 6 -> 4 - TRANSPORT_EVT_DISCARD_COUNT: 2 -> 1 - HOST_TASK_STACK_SIZE: 4096 -> 3072 (new) Reclaims roughly 6-8 KB of static NimBLE budget. Trade-off surfaces as "missed page-turn notify" not "crash" under rapid button presses, which is the right direction to fail. --- lib/hal/BluetoothHIDManager.cpp | 40 +++++++++++++++++++++++---------- platformio.ini | 26 ++++++++++++++++----- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/lib/hal/BluetoothHIDManager.cpp b/lib/hal/BluetoothHIDManager.cpp index d5b253f3..eb27d5de 100644 --- a/lib/hal/BluetoothHIDManager.cpp +++ b/lib/hal/BluetoothHIDManager.cpp @@ -485,26 +485,42 @@ bool BluetoothHIDManager::connectToDevice(const std::string& address) { static ClientCallbacks clientCallbacks; pClient->setClientCallbacks(&clientCallbacks); - // Connect to device + // Connect to device. First attempt frequently times out for game-pad + // peripherals that haven't been awake for a while: the controller is + // still discovering / advertising and our scan-window misses it. User + // pattern is reliably "first quick-connect fails, second one works". + // + // CrumBLE 4.4 task #28-follow: always retry at least once with a fresh + // client + a small cool-down between attempts. The fresh-client path + // also covers the existing-client-from-previous-session case (where + // stale state in NimBLE's host stack rejects the reconnect). if (!pClient->connect(bleAddress)) { - if (hadExistingClient) { - LOG_INF("BT", "Reconnect with existing client failed for %s, retrying with fresh client", address.c_str()); - NimBLEClient* freshClient = NimBLEDevice::createClient(bleAddress); - if (freshClient) { - pClient = freshClient; - pClient->setSelfDelete(false, false); - pClient->setConnectTimeout(BLE_CONNECT_TIMEOUT_MS); - pClient->setConnectionParams(BLE_CONN_MIN_INTERVAL, BLE_CONN_MAX_INTERVAL, BLE_CONN_LATENCY, - BLE_CONN_TIMEOUT, BLE_CONN_SCAN_INTERVAL, BLE_CONN_SCAN_WINDOW); - pClient->setClientCallbacks(&clientCallbacks); + LOG_INF("BT", "Initial connect attempt failed for %s; retrying after cool-down", address.c_str()); + // ~300 ms cool-down: long enough for the controller to settle a + // pending advertising window, short enough that the user doesn't + // perceive a stall on the rare-but-real case where the first attempt + // actually races RF noise rather than peripheral state. NimBLE + // controller event loop keeps ticking through delay(). + delay(300); + NimBLEClient* freshClient = NimBLEDevice::createClient(bleAddress); + if (freshClient) { + if (hadExistingClient) { + LOG_INF("BT", "Reconnect with existing client failed; using fresh client"); } + pClient = freshClient; + pClient->setSelfDelete(false, false); + pClient->setConnectTimeout(BLE_CONNECT_TIMEOUT_MS); + pClient->setConnectionParams(BLE_CONN_MIN_INTERVAL, BLE_CONN_MAX_INTERVAL, BLE_CONN_LATENCY, + BLE_CONN_TIMEOUT, BLE_CONN_SCAN_INTERVAL, BLE_CONN_SCAN_WINDOW); + pClient->setClientCallbacks(&clientCallbacks); } if (!pClient->connect(bleAddress)) { lastError = "Connection failed"; - LOG_ERR("BT", "Failed to connect to %s", address.c_str()); + LOG_ERR("BT", "Failed to connect to %s (after retry)", address.c_str()); return false; } + LOG_INF("BT", "Connect succeeded on retry for %s", address.c_str()); } const bool connParamsUpdated = diff --git a/platformio.ini b/platformio.ini index c0b106fa..daf480fb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -68,14 +68,30 @@ build_flags = ; page-turner needs a fraction of these, so the rest is reclaimable heap that the ; reader's glyph buffers need under BLE. Conservative cuts; validate BLE connects ; + stays + text renders on-device. - -DCONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=64 - -DCONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=4 - -DCONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=4 - -DCONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=6 - -DCONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT=2 +; CrumBLE 4.4 task #23 round 2: drop another ~6-8 KB of NimBLE static +; budget. The first-round cuts above sit inside a heap that NimBLE still +; balloons by ~75 KB on connect (atlas + subset-skip recover heap into BT +; enable but the post-connect MaxAlloc still bottoms out at ~5-9 KB and +; aborts on downstream allocs). This round trims the runtime pools further +; on the bet that an HID-only single-link central can survive smaller +; mbuf/ACL/event pools: a game brick sends short notify packets at low rate, +; never bulk transfers, so the previously-conservative buffer counts had +; substantial slack. If a controller starts dropping notifications under +; rapid button presses we'll revisit -- the cost surfaces as "missed page +; turn" not "crash", which is the right direction to fail. + -DCONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23 + -DCONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=3 + -DCONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=2 + -DCONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=4 + -DCONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT=1 -DCONFIG_BT_NIMBLE_GATT_MAX_PROCS=1 -DCONFIG_BT_NIMBLE_MAX_CCCDS=6 -DCONFIG_BT_NIMBLE_HS_FLOW_CTRL=0 +; Shrink the NimBLE host task stack from default 4096 to 3072. The host +; task is mostly state-machine + callback dispatch; it doesn't recurse +; deep into long call chains in our usage (single conn, no peripheral +; role). Reclaims 1 KB of task stack -> general heap. + -DCONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=3072 # https://libexpat.github.io/doc/api/latest/#XML_GE -DXML_GE=0 -DXML_CONTEXT_BYTES=1024 From a0942973cd7a2acf236e813b69b6874f6fa2a5b6 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Wed, 10 Jun 2026 21:28:27 -0700 Subject: [PATCH 04/10] fix(reader): UX polish + lookup-during-bt guard + atlas-subset-skip Four reader-activity changes addressing memory pressure and UX bugs surfaced during v4.4 atlas testing: 1. EpubReaderActivity lazy-reload skips embedded-subset install when the v40 atlas is already covering the section. atlasOk previously only flipped true when the lazy reload itself installed the atlas; when the atlas was installed at section-open and the lazy reload only needed to install the subset, the subset install fired unconditionally and burned ~6 KB of MaxAlloc on redundant data. Track atlasUsable = atlasOk || section->glyphAtlasInstalled() and only fall through to subset install when no atlas data exists. 2. EpubReaderActivity prebake-mismatch dialog: treat the Cancel / Back button as "return to library" rather than silently falling through to "Keep current settings" and entering the book with mismatched settings. finish() pops the reader activity so the library renders again immediately. The two visible buttons keep their semantics; only the implicit back-out semantics change. 3. EpubReaderMenuActivity hides Lookup / Looked-Up Words / Add Highlight / Finish Highlight / Cancel Highlight entries when a BLE remote is currently active. NimBLE pins ~58 KB of fragmented heap while connected; the lookup word-select pass and highlight word-walk both allocate WordInfo vectors that bad_alloc'd under tight MaxAlloc, then dragged the auto-disable-BT recovery into a fragile reconnect race that aborted with maxAlloc=5 KB. Hiding the entries is the honest tradeoff: user disconnects BT first, runs lookup, then reconnects. 4. FontSelectionActivity two fixes for the in-book Font Family submenu: (a) onEnter requests an immediate update so the first frame paints before the user has to scroll, (b) Back is handled on wasReleased instead of wasPressed so the trailing release event does not leak to the parent ReaderOptionsActivity (which also listens on Back release) and cause a double-pop to the in-book main menu. --- src/activities/reader/EpubReaderActivity.cpp | 48 ++++++++++++++++--- .../reader/EpubReaderMenuActivity.cpp | 42 +++++++++++++--- .../settings/FontSelectionActivity.cpp | 19 +++++++- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 153136cf..ecaac1af 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -891,14 +891,28 @@ bool EpubReaderActivity::checkAndFirePrebakePromptIfNeeded() { [this](const ActivityResult& result) { prebakePromptShowing_ = false; // ChoicePromptResult lives inside result.data. choice: 0 = "Keep - // my current settings", 1 = "Restore prepared layout", -1 = - // user backed out. Treat back-out as "Keep" -- the less - // destructive default. + // my current settings", 1 = "Restore prepared layout", -1 = user + // hit the prompt's Cancel/back button. + // + // CrumBLE 4.4 follow-up: treat Cancel as "back to where I was + // before the prompt fired". The prompt fires immediately on book + // open when prebake settings drift from the current SETTINGS, so + // "before the prompt" = the library carousel. Previously back-out + // silently fell through to the "Keep current settings" path, + // which then indexed + entered the book with mismatched settings + // -- the opposite of what a Cancel button should do. finish() + // pops the reader activity off the stack so the library renders + // again immediately. int chosen = -1; if (const auto* cp = std::get_if(&result.data)) { chosen = cp->choice; } - const bool keepCurrent = result.isCancelled || chosen != 1; + if (result.isCancelled) { + LOG_INF("ERA", "Prebake prompt: cancelled, returning to library"); + finish(); + return; + } + const bool keepCurrent = chosen != 1; if (keepCurrent) { // User declined -- keep their current settings. Don't delete the // prebake (Section.cpp's clearCache only ever touches sections/, @@ -3761,12 +3775,32 @@ void EpubReaderActivity::render(RenderLock&& lock) { atlasOk = section->tryInstallGlyphAtlas(it->second->contentHash()); } bool subsetOk = section->embeddedSubsetInstalled(); - if (!atlasOk && subsetNeedsReload) { + // CrumBLE 4.4 v4.4.1: when the atlas is already providing data + // (either freshly installed above OR installed at section-open and + // still resident), the v39 subset is redundant -- both blocks are + // emitted from the same prewarmed glyph working set, so they cover + // identical codepoints with identical metrics. Installing the + // subset alongside the atlas burns ~6 KB of MaxAlloc and ~5 KB of + // free heap (see lazy-reload log line that motivated this change: + // `atlas=0 subset=1 (free 56592->49364, maxAlloc 49140->42996)` -- + // the atlas was already installed at section-open, so the lazy + // reload's subset install was pure overhead going into the BT + // enable window). Only fall through to the subset when there's no + // atlas data at all -- either because the section was baked + // pre-v40 (no atlas block) OR the atlas install above failed under + // tight heap. + const bool atlasUsable = atlasOk || section->glyphAtlasInstalled(); + if (!atlasUsable && subsetNeedsReload) { // Atlas unavailable or install failed; fall back to v39 subset. subsetOk = section->tryInstallEmbeddedGlyphSubset(it->second->contentHash()); } - LOG_INF("ERA", "Lazy glyph data reload: atlas=%d subset=%d (free %u->%u, maxAlloc %u->%u)", - atlasOk, subsetOk, freeBefore, ESP.getFreeHeap(), maxAllocBefore, ESP.getMaxAllocHeap()); + // Log skipped subset reloads distinctly so the trace shows + // "atlas already covering" vs "both install failed" -- previously + // both produced atlas=0 subset=0 with no heap delta. + const bool subsetSkippedForAtlas = atlasUsable && subsetNeedsReload && !subsetOk; + LOG_INF("ERA", "Lazy glyph data reload: atlas=%d subset=%d%s (free %u->%u, maxAlloc %u->%u)", + atlasOk, subsetOk, subsetSkippedForAtlas ? " (subset skipped: atlas covering)" : "", + freeBefore, ESP.getFreeHeap(), maxAllocBefore, ESP.getMaxAllocHeap()); auto glyphFontData = [&](uint8_t style) -> const EpdFontData* { if (section->glyphAtlasInstalled()) { if (const EpdFontData* atlas = section->glyphAtlasFontDataForStyle(style)) return atlas; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 2bffa253..98a0f75e 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -148,18 +148,46 @@ std::vector EpubReaderMenuActivity::buildMainM // the SD card" info screen instead of launching the word-select flow. // LOOKED_UP_WORDS stays gated -- the history list only makes sense // when a dictionary is actually present and has been used. - items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP}); - if (hasDictionary && hasLookupHistory) { - items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKED_UP_WORDS}); + // + // CrumBLE 4.4 BT guardrail: hide Lookup / Looked-Up Words / Highlight + // entries entirely when a BLE remote is currently active. The lookup + // word-select pass + highlight word-walk both allocate a WordInfo vector + // sized by the page's word count, and NimBLE pins ~58 KB of fragmented + // heap while a remote is connected. The "auto-disable BT, run lookup, + // re-enable BT" path we shipped previously turned a low-heap path into + // a fragile reconnect dance that aborts when the recovery doesn't free + // enough headroom (logs show MaxAlloc dipping to ~5 KB post-reconnect + // before the lookup buffer alloc fails). Hiding the entries is the + // honest tradeoff: the user disconnects BT first, runs lookup, then + // reconnects -- no chance of an in-flight reconnect-while-lookup race. + // BluetoothHIDManager::isEnabled() is the source of truth: it's true + // from BT-on through BT-off, covering both connected and "scanning to + // reconnect" states which are equally heap-pressured. + const bool bleActive = BluetoothHIDManager::getInstance().isEnabled(); + if (!bleActive) { + items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP}); + if (hasDictionary && hasLookupHistory) { + items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKED_UP_WORDS}); + } } (void)hasDictionary; // Highlight quick action. Pending-hold state replaces Add with the // Finish/Cancel pair so the menu doesn't dangle two ways to start. - if (hasPendingHighlight) { - items.push_back({MenuAction::FINISH_HIGHLIGHT, StrId::STR_FINISH_HIGHLIGHT}); - items.push_back({MenuAction::CANCEL_HIGHLIGHT, StrId::STR_CANCEL_HIGHLIGHT}); + // Hidden when BLE is active (same heap-pressure rationale as Lookup). + if (!bleActive) { + if (hasPendingHighlight) { + items.push_back({MenuAction::FINISH_HIGHLIGHT, StrId::STR_FINISH_HIGHLIGHT}); + items.push_back({MenuAction::CANCEL_HIGHLIGHT, StrId::STR_CANCEL_HIGHLIGHT}); + } else { + items.push_back({MenuAction::ADD_HIGHLIGHT, StrId::STR_ADD_HIGHLIGHT}); + } } else { - items.push_back({MenuAction::ADD_HIGHLIGHT, StrId::STR_ADD_HIGHLIGHT}); + // BLE active: drop the pending highlight if any was in flight so the + // user doesn't return from a BT session with a half-started highlight + // hanging around. The pendingHighlight state is fed in from the + // activity; we can't mutate it here, but the menu user will see no + // dangling Finish/Cancel option, which matches expectation. + (void)hasPendingHighlight; } items.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_INTERVAL_SECONDS}); diff --git a/src/activities/settings/FontSelectionActivity.cpp b/src/activities/settings/FontSelectionActivity.cpp index 4e1655c8..6c8b032c 100644 --- a/src/activities/settings/FontSelectionActivity.cpp +++ b/src/activities/settings/FontSelectionActivity.cpp @@ -111,13 +111,28 @@ void FontSelectionActivity::onEnter() { selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0; } - requestUpdate(); + // CrumBLE 4.4 task #43: use immediate notify so the first render fires + // before the next user input. Without immediate=true, the requested + // update flag set here can be coalesced with the parent (ReaderOptions) + // activity's still-in-flight render notify, so the first FontSelection + // frame doesn't appear until the user presses up/down (which triggers + // its own requestUpdate). xTaskNotify with eIncrement is idempotent so + // an extra wake-up is harmless if the render task is already queued. + requestUpdate(/*immediate=*/true); } void FontSelectionActivity::onExit() { Activity::onExit(); } void FontSelectionActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + // CrumBLE 4.4 task #44: use wasReleased(Back) instead of wasPressed + // to stay consistent with the parent ReaderOptionsActivity (which also + // listens on Back release). Without this, the press event finishes + // FontSelection, ReaderOptions becomes the new current activity, and + // the matching release event still in mappedInput's queue immediately + // triggers ReaderOptions's own Back-on-release handler -- popping a + // second level back to the in-book main menu in a single user gesture. + // Confirm is already wasReleased for the same reason. + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { finish(); return; } From 024358ca9acbf88d9f9681afcb001453bc14af02 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:53:25 -0700 Subject: [PATCH 05/10] feat(reader): dark mode + Text Darkness + paragraph spacing + pt labels Rendering-layer additions for v4.5: - Dark Reader Mode (1:1 port from CrossInk v1.3.2): black page background, white body text, status bar inverted, selection highlight + lookup popup flip via reverse-video, drawer panel goes dark. foregroundBlack threaded through Page/PageLine/TextBlock/PageHorizontalRule render chain so SD font glyphs render the right colour in either mode. - Text Darkness (ported from CPR-vCodex with 1px BW outline addition for Extra Dark): 4-mode setting (Normal / Legacy BW / Dark / Extra Dark) tunes the 2-bit grayscale glyph blit's MSB/LSB hit pattern. Extra Dark adds a 1px horizontal outline in the BW pass so the bolder weight reads even when Text AA is off or the AA path silently skips on low heap. - Paragraph Spacing 3-way enum (Tight / Normal / Wide) replacing the prior binary toggle; on-disk byte format is unchanged so old configs migrate. - Font sizes shown as Npt labels instead of friendly names (Tiny / Small / ...) so SD-card font sizes interleave naturally with built-in sizes. - URL-decode for OPF hrefs + nav doc + NCX + img src; books with %20 or %26 in chapter filenames no longer hang at chapter entry (the encoded names never matched the literal ZIP central directory entries). - Theme primitives (BaseTheme/Lyra/Minimal/RoundedRaff drawStatusBar + drawButtonHints) gained darkMode param so the in-reader status bar inverts correctly, and button hints auto-flip when called from a dark-mode wordselect context. - Prebake viewer: font value rendered as Npt (instead of step/range) and split into two labeled rows (Font + Font Size) so the second line isn't a blank-label gap. --- lib/EpdFont/SdCardFont.cpp | 67 ++- lib/EpdFont/SdCardFont.h | 14 + lib/Epub/Epub/Page.cpp | 84 ++- lib/Epub/Epub/Page.h | 14 +- lib/Epub/Epub/Section.cpp | 104 +++- lib/Epub/Epub/Section.h | 38 +- lib/Epub/Epub/blocks/TextBlock.cpp | 497 ++++++++++++------ lib/Epub/Epub/blocks/TextBlock.h | 187 +++++-- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 12 +- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 4 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 10 +- lib/Epub/Epub/parsers/TocNavParser.cpp | 5 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 3 +- lib/FsHelpers/FsHelpers.cpp | 28 + lib/FsHelpers/FsHelpers.h | 9 + lib/GfxRenderer/GfxRenderer.cpp | 65 ++- lib/GfxRenderer/GfxRenderer.h | 6 + lib/I18n/translations/english.yaml | 13 +- src/CrossPointSettings.cpp | 2 + src/CrossPointSettings.h | 38 ++ src/SettingsList.h | 115 ++-- .../reader/BookSettingsDrawerActivity.cpp | 51 +- .../reader/DictionaryWordSelectActivity.cpp | 372 ++++++++++++- .../reader/DictionaryWordSelectActivity.h | 42 ++ .../reader/PrebakeManifestViewerActivity.cpp | 36 +- src/activities/reader/ReaderUtils.h | 21 + src/activities/reader/TxtReaderActivity.cpp | 16 +- src/components/themes/BaseTheme.cpp | 107 ++-- src/components/themes/BaseTheme.h | 24 +- src/components/themes/lyra/LyraTheme.cpp | 33 +- src/components/themes/lyra/LyraTheme.h | 5 +- .../themes/minimal/MinimalTheme.cpp | 21 +- src/components/themes/minimal/MinimalTheme.h | 2 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 25 +- .../themes/roundedraff/RoundedRaffTheme.h | 2 +- 35 files changed, 1613 insertions(+), 459 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index b34cf5b8..f02ad5df 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -146,6 +146,11 @@ void SdCardFont::freeAll() { styleCount_ = 0; contentHash_ = 0; loaded_ = false; + // CrumBLE 4.4 task #51: release the persistent glyph file handle on + // unload. Re-opened on the next load(). + if (persistentGlyphFile_.isOpen()) { + persistentGlyphFile_.close(); + } } void SdCardFont::clearOverflow() { @@ -624,6 +629,36 @@ bool SdCardFont::load(const char* path) { loaded_ = true; + // CrumBLE 4.4 task #51: open the persistent glyph-miss file handle + // NOW while the heap is still healthy. onGlyphMiss() reuses this + // handle (seek + read, no allocation) instead of opening the file + // fresh on every miss. Pre-NimBLE heap is comfortable, so the open + // will succeed; under post-NimBLE pressure where the per-call open + // would fail, this handle is already open and the seek/read + // succeeds. If this open fails for some reason, persistentGlyphFile_ + // stays closed and onGlyphMiss falls back to its original per-call + // open path -- graceful degradation, no behavior change vs old. + if (!Storage.openFileForRead("SDCF", filePath_, persistentGlyphFile_)) { + LOG_ERR("SDCF", "Failed to pre-open persistent glyph file; " + "onGlyphMiss will fall back to per-call open path"); + } + + // CrumBLE 4.4 task #51: also eagerly load each style's full intervals + // table NOW so onGlyphMiss never triggers the lazy + // ensureStyleIntervalsLoaded path -- that path opens the file AND + // allocates `new EpdUnicodeInterval[intervalCount]`, both of which + // fail under post-NimBLE heap pressure. Doing it here costs maybe a + // few KB of resident memory per book but happens against a healthy + // boot heap. The lazy entry point still exists for other callers + // (e.g. prebake tools) and as a fallback if this eager load fails. + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + if (!ensureStyleIntervalsLoaded(i)) { + LOG_ERR("SDCF", "Failed to pre-load intervals for style %u; " + "onGlyphMiss will retry the lazy load (may fail on tight heap)", i); + } + } + LOG_DBG("SDCF", "Loaded: %s (v%u, %u styles)", path, CPFONT_VERSION, styleCount_); for (uint8_t i = 0; i < MAX_STYLES; i++) { if (!styles_[i].present) continue; @@ -1340,18 +1375,34 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { uint32_t slot = self->overflowNext_; bool wasAtCapacity = (self->overflowCount_ == OVERFLOW_CAPACITY); - // Read glyph metadata into temporary - FsFile file; - if (!Storage.openFileForRead("SDCF", self->filePath_, file)) { - LOG_ERR("SDCF", "Overflow: failed to open .cpfont"); - return nullptr; + // CrumBLE 4.4 task #51: use the persistent file handle when + // available. The per-call Storage.openFileForRead path allocates + // ~12 bytes for HalFile::Impl and crashes under post-NimBLE heap + // pressure. The persistent handle was opened during load() while + // the heap was healthy; here we just seek and read on it. Fallback + // to the per-call open path if the persistent handle isn't open + // (load-time pre-open failed -- graceful degradation). + FsFile fallbackFile; + HalFile* fileRef = nullptr; + if (self->persistentGlyphFile_.isOpen()) { + fileRef = &self->persistentGlyphFile_; + } else { + if (!Storage.openFileForRead("SDCF", self->filePath_, fallbackFile)) { + LOG_ERR("SDCF", "Overflow: failed to open .cpfont"); + return nullptr; + } + fileRef = &fallbackFile; } + HalFile& file = *fileRef; EpdGlyph tempGlyph = {}; uint32_t glyphFileOff = s.glyphsFileOffset + static_cast(globalIdx) * sizeof(EpdGlyph); if (!file.seekSet(glyphFileOff)) { LOG_ERR("SDCF", "Overflow: failed to seek to glyph for U+%04X style %u", codepoint, styleIdx); - file.close(); + // Do NOT close on seek failure -- file is still valid, only the + // seek failed. Closing would force every future miss back to the + // per-call open path, defeating the fix. fallbackFile (if used) + // closes itself on scope exit. return nullptr; } if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { @@ -1370,7 +1421,9 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { if (!file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset)) { LOG_ERR("SDCF", "Overflow: failed to seek to bitmap for U+%04X", codepoint); delete[] tempBitmap; - file.close(); + // CrumBLE 4.4 task #51: do NOT close the persistent handle on a + // bad seek -- the file is still valid. Closing would force every + // subsequent miss back to the per-call open path. return nullptr; } if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index f270957a..9cf2bbd3 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -4,6 +4,8 @@ #include #include +#include // CrumBLE 4.4 task #51: HalFile persistentGlyphFile_ + #include "EpdFont.h" #include "EpdFontData.h" @@ -228,6 +230,18 @@ class SdCardFont { char filePath_[128] = {}; + // CrumBLE 4.4 task #51: persistent file handle for SdCardFont::onGlyphMiss. + // Without this, every glyph miss opens the .cpfont fresh via + // Storage.openFileForRead -> make_unique which allocates + // ~12 bytes + FsFile state. Under post-NimBLE heap pressure (MaxAlloc + // 500-2000 bytes after BT connect, lower on memory-tight books) that + // allocation fails -> bad_alloc -> __terminate -> panic-reboot. The + // handle is opened during load() while the heap is healthy and reused + // for every glyph miss; onGlyphMiss does only seek + read on it + // (no allocations). If the load-time open fails, onGlyphMiss falls + // back to the original per-call open path. + HalFile persistentGlyphFile_; + // Overflow context: glyphMissHandler needs to know which style it's serving struct OverflowContext { SdCardFont* self; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 31ace23d..af546e95 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -1,5 +1,6 @@ #include "Page.h" +#include // ESP.getMaxAllocHeap() for footnote.resize() pre-flight #include #include #include @@ -16,18 +17,20 @@ static_assert(PageTableFragment::MAX_SERIALIZED_ROWS == MAX_TABLE_ROWS_PER_FRAGM template void renderFilteredPageElements(const std::vector>& elements, GfxRenderer& renderer, - const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) { + const int fontId, const int xOffset, const int yOffset, const bool foregroundBlack, + Predicate&& predicate) { for (const auto& element : elements) { if (predicate(*element)) { - element->render(renderer, fontId, xOffset, yOffset); + element->render(renderer, fontId, xOffset, yOffset, foregroundBlack); } } } } // namespace -void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { - block->render(renderer, fontId, xPos + xOffset, yPos + yOffset); +void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) { + block->render(renderer, fontId, xPos + xOffset, yPos + yOffset, foregroundBlack); } bool PageLine::serialize(FsFile& file) { @@ -54,6 +57,25 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { return nullptr; } + // CrumBLE 4.4 post-bisect: pre-flight before constructing PageLine. + // The constructor converts the unique_ptr argument into a + // shared_ptr member, which allocates a control block (~24 + // bytes on this libstdc++) using a regular (throwing) new. With + // -fno-exceptions, that allocation either aborts or returns a corrupt + // pointer on OOM -- we've observed the latter, where the control + // block's mutex pointer ends up at a garbage address (e.g. 0x00530000), + // and the eventual cachedRenderPage_.reset() destruction then crashes + // in pthread_mutex_destroy. Refuse up-front rather than risk poisoning + // the page DOM with a corrupt PageLine that survives heap recovery and + // panic-crashes later. + // + // 96-byte margin = sizeof(PageLine) ~32 + shared_ptr ctrl block ~24 + // + bookkeeping. Cheap to over-reserve. + if (ESP.getMaxAllocHeap() < 96) { + LOG_ERR("PGE", "Refusing PageLine alloc: maxAlloc=%u < 96 (risk of corrupt shared_ptr ctrl block)", + ESP.getMaxAllocHeap()); + return nullptr; + } auto* pageLine = new (std::nothrow) PageLine(std::move(tb), xPos, yPos); if (!pageLine) { LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine"); @@ -62,8 +84,11 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { return std::unique_ptr(pageLine); } -void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { - // Images don't use fontId or text rendering +void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) { + // CrumBLE 4.4: foregroundBlack ignored -- EPUB content images render + // right-side-up in dark mode (no negative-photo effect). + (void)foregroundBlack; imageBlock->render(renderer, xPos + xOffset, yPos + yOffset); } @@ -99,13 +124,15 @@ std::unique_ptr PageImage::deserialize(FsFile& file) { return std::unique_ptr(pageImage); } -void PageHorizontalRule::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { +void PageHorizontalRule::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) { (void)fontId; if (width == 0 || thickness == 0) { return; } - renderer.drawLine(xPos + xOffset, yPos + yOffset, xPos + xOffset + width - 1, yPos + yOffset, thickness, true); + renderer.drawLine(xPos + xOffset, yPos + yOffset, xPos + xOffset + width - 1, yPos + yOffset, thickness, + foregroundBlack); } bool PageHorizontalRule::serialize(FsFile& file) { @@ -233,7 +260,8 @@ uint16_t PageTableFragment::getHeight() const { return total; } -void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { +void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) { if (columnCount == 0 || rows.empty() || width < 2) { return; } @@ -248,10 +276,10 @@ void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const in } columnStarts[columnCount] = static_cast(width - 1); - renderer.drawRect(drawX, drawY, width, totalHeight, true); + renderer.drawRect(drawX, drawY, width, totalHeight, foregroundBlack); for (uint8_t i = 1; i < columnCount; i++) { const int x = drawX + columnStarts[i]; - renderer.drawLine(x, drawY, x, drawY + totalHeight - 1, true); + renderer.drawLine(x, drawY, x, drawY + totalHeight - 1, foregroundBlack); } int currentY = 0; @@ -265,14 +293,14 @@ void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const in for (size_t lineIndex = 0; lineIndex < cell.lines.size(); lineIndex++) { cell.lines[lineIndex]->render(renderer, fontId, cellTextX, - cellTextY + static_cast(lineIndex) * lineHeight); + cellTextY + static_cast(lineIndex) * lineHeight, foregroundBlack); } } currentY += row.height; if (rowIndex + 1 < rows.size()) { const int lineWidth = row.headerSeparator ? 2 : 1; - renderer.drawLine(drawX, drawY + currentY, drawX + width - 1, drawY + currentY, lineWidth, true); + renderer.drawLine(drawX, drawY + currentY, drawX + width - 1, drawY + currentY, lineWidth, foregroundBlack); } } } @@ -340,17 +368,21 @@ std::unique_ptr PageTableFragment::deserialize(FsFile& file) return std::unique_ptr(fragment); } -void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { - renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; }); +void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) const { + renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, foregroundBlack, + [](const PageElement&) { return true; }); } -void Page::renderText(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { - renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, +void Page::renderText(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool foregroundBlack) const { + renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, foregroundBlack, [](const PageElement& element) { return element.getTag() != TAG_PageImage; }); } void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { - renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, + // Images always paint right-side-up regardless of dark mode. + renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, /*foregroundBlack=*/true, [](const PageElement& element) { return element.getTag() == TAG_PageImage; }); } @@ -461,7 +493,21 @@ std::unique_ptr Page::deserialize(FsFile& file) { LOG_ERR("PGE", "Invalid footnote count %u", fnCount); return nullptr; } - page->footnotes.resize(fnCount); + // CrumBLE 4.4 post-bisect: pre-flight gate around footnote vector resize. + // Build runs with -fno-exceptions, so the only way to avoid bad_alloc -> + // __cxa_allocate_exception -> terminate is to never reach the throw. + // FootnoteEntry is ~80 bytes; refuse the page if MaxAlloc can't cover it. + // Only check when fnCount > 0 -- resize(0) is a no-op that should never + // be refused even when heap is critically fragmented. + if (fnCount > 0) { + const uint32_t needed = static_cast(fnCount) * sizeof(FootnoteEntry); + if (ESP.getMaxAllocHeap() < needed + 256) { + LOG_ERR("PGE", "Refusing footnote.resize(%u): maxAlloc=%u < needed=%u", + fnCount, ESP.getMaxAllocHeap(), needed + 256); + return nullptr; + } + page->footnotes.resize(fnCount); + } for (uint16_t i = 0; i < fnCount; i++) { auto& entry = page->footnotes[i]; if (file.read(entry.number, sizeof(entry.number)) != sizeof(entry.number) || diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index fbd21591..4f0ef176 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -24,7 +24,7 @@ class PageElement { int16_t yPos; explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} virtual ~PageElement() = default; - virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; + virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) = 0; virtual bool serialize(FsFile& file) = 0; virtual PageElementTag getTag() const = 0; // Add type identification }; @@ -37,7 +37,7 @@ class PageLine final : public PageElement { PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} const std::shared_ptr& getBlock() const { return block; } - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) override; bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageLine; } static std::unique_ptr deserialize(FsFile& file); @@ -50,7 +50,7 @@ class PageImage final : public PageElement { public: PageImage(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), imageBlock(std::move(block)) {} - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) override; bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageImage; } static std::unique_ptr deserialize(FsFile& file); @@ -65,7 +65,7 @@ class PageHorizontalRule final : public PageElement { PageHorizontalRule(uint16_t width, uint8_t thickness, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), width(width), thickness(thickness) {} - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) override; bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageHorizontalRule; } static std::unique_ptr deserialize(FsFile& file); @@ -111,7 +111,7 @@ class PageTableFragment final : public PageElement { lineHeight(lineHeight), rows(std::move(rows)) {} - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) override; bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageTableFragment; } static std::unique_ptr deserialize(FsFile& file); @@ -140,8 +140,8 @@ class Page { footnotes.push_back(entry); } - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; - void renderText(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) const; + void renderText(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) const; void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 52480036..9c4acf11 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -45,7 +45,7 @@ constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" // emit zeros for all three fields; the loader treats that as "no atlas // available, fall back to the v39 subset / SD-font miss handler path". // See lib/Epub/Epub/GlyphAtlas.h for the on-disk block format. -constexpr uint8_t SECTION_FILE_VERSION = 40; +constexpr uint8_t SECTION_FILE_VERSION = 41; // Oldest section file version this firmware can still read (forward-compat // window). v38 sections produced by v4.2.x prebakes / live caches still load // cleanly; their embedded glyph subset offsets default to 0 (no subset). @@ -112,7 +112,8 @@ constexpr uint32_t HEADER_SIZE_V38 = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_ // v39 adds 3 uint32_t trailer fields after the liLutOffset (embedded glyph // subset offset/size/hash). v40 adds 3 more (glyph atlas offset/size/hash). constexpr uint32_t HEADER_SIZE_V39 = HEADER_SIZE_V38 + 3 * sizeof(uint32_t); -constexpr uint32_t HEADER_SIZE = HEADER_SIZE_V39 + 3 * sizeof(uint32_t); +constexpr uint32_t HEADER_SIZE_V40 = HEADER_SIZE_V39 + 3 * sizeof(uint32_t); +constexpr uint32_t HEADER_SIZE = HEADER_SIZE_V40 + 3 * sizeof(uint32_t); // +3 for v41 alt-atlas trailer // The embedded glyph subset block format constants live in // EmbeddedGlyphSubset.h (namespace embeddedGlyphSubset) so the on-device @@ -146,7 +147,7 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { return position; } -bool Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, +bool Section::writeSectionFileHeader(const int fontId, const float lineCompression, const uint8_t extraParagraphSpacing, const bool forceParagraphIndents, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, @@ -170,7 +171,10 @@ bool Section::writeSectionFileHeader(const int fontId, const float lineCompressi sizeof(uint32_t) /*embeddedGlyphSubsetCpfontHash (v39)*/ + sizeof(uint32_t) /*glyphAtlasOffset (v40)*/ + sizeof(uint32_t) /*glyphAtlasSize (v40)*/ + - sizeof(uint32_t) /*glyphAtlasCpfontHash (v40)*/, + sizeof(uint32_t) /*glyphAtlasCpfontHash (v40)*/ + + sizeof(uint32_t) /*glyphAtlasAltOffset (v41)*/ + + sizeof(uint32_t) /*glyphAtlasAltSize (v41)*/ + + sizeof(uint32_t) /*glyphAtlasAltCpfontHash (v41)*/, "Header size mismatch"); return serialization::tryWritePod(file, SECTION_CACHE_MAGIC) && serialization::tryWritePod(file, SECTION_FILE_VERSION) && serialization::tryWritePod(file, fontId) && @@ -205,10 +209,17 @@ bool Section::writeSectionFileHeader(const int fontId, const float lineCompressi // block in the buildGlyphAtlasBlock path. serialization::tryWritePod(file, static_cast(0)) && // glyphAtlasOffset serialization::tryWritePod(file, static_cast(0)) && // glyphAtlasSize - serialization::tryWritePod(file, static_cast(0)); // glyphAtlasCpfontHash + serialization::tryWritePod(file, static_cast(0)) && // glyphAtlasCpfontHash + // v41 trailer: three uint32_t fields for the alternate atlas slot + // (the OTHER bit-depth of the same glyph set). All zero on bakes + // that only produced one bit-depth. Patched by the prebake CLI's + // atlas-emit path when --emit-section-glyph-subsets is set. + serialization::tryWritePod(file, static_cast(0)) && // glyphAtlasAltOffset + serialization::tryWritePod(file, static_cast(0)) && // glyphAtlasAltSize + serialization::tryWritePod(file, static_cast(0)); // glyphAtlasAltCpfontHash } -bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, +bool Section::loadSectionFile(const int fontId, const float lineCompression, const uint8_t extraParagraphSpacing, const bool forceParagraphIndents, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering, @@ -251,7 +262,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con } bool Section::tryLoadFromPath(const std::string& path, const int fontId, const float lineCompression, - const bool extraParagraphSpacing, const bool forceParagraphIndents, + const uint8_t extraParagraphSpacing, const bool forceParagraphIndents, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering, const bool bionicReadingEnabled, @@ -296,7 +307,7 @@ bool Section::tryLoadFromPath(const std::string& path, const int fontId, const f int fileFontId; uint16_t fileViewportWidth, fileViewportHeight; float fileLineCompression; - bool fileExtraParagraphSpacing; + uint8_t fileExtraParagraphSpacing; bool fileForceParagraphIndents; uint8_t fileParagraphAlignment; bool fileHyphenationEnabled; @@ -409,6 +420,9 @@ bool Section::tryLoadFromPath(const std::string& path, const int fontId, const f glyphAtlasOffset_ = 0; glyphAtlasSize_ = 0; glyphAtlasCpfontHash_ = 0; + glyphAtlasAltOffset_ = 0; + glyphAtlasAltSize_ = 0; + glyphAtlasAltCpfontHash_ = 0; if (fileVersion_ >= 39) { if (!file.seek(HEADER_SIZE_V38)) { file.close(); @@ -436,6 +450,20 @@ bool Section::tryLoadFromPath(const std::string& path, const int fontId, const f return false; } } + if (fileVersion_ >= 41) { + // v41 alternate atlas trailer sits immediately after the v40 trailer. + // Same offset-progression pattern: file position advanced by the v40 + // reads, no seek needed. v40 files don't have these bytes, so we + // leave the alt fields at 0 and the install path falls through to + // the single (v40) atlas slot. + if (!serialization::tryReadPod(file, glyphAtlasAltOffset_) || + !serialization::tryReadPod(file, glyphAtlasAltSize_) || + !serialization::tryReadPod(file, glyphAtlasAltCpfontHash_)) { + file.close(); + LOG_ERR("SCT", "Deserialization failed: truncated v41 glyph-atlas-alt trailer (%s)", path.c_str()); + return false; + } + } // Explicit close() required: member variable persists beyond function scope file.close(); @@ -464,7 +492,7 @@ bool Section::clearCache() const { return true; } -bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, +bool Section::createSectionFile(const int fontId, const float lineCompression, const uint8_t extraParagraphSpacing, const bool forceParagraphIndents, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering, @@ -740,15 +768,18 @@ std::unique_ptr Section::loadPageFromSectionFile() { // hard reboot. 25 KB threshold covers a typical 150-300 word page // with overhead headroom; the original crash had maxAlloc well below // this floor. - // CrumBLE: page-load floor lowered to 8 KB. Empirical measurements show - // typical page peak contiguous allocation = 5-8 KB. The previous 25 KB - // floor was over-budgeted, refusing loads under BT pressure that would - // have succeeded. v3.7.3 (no floor) worked because most pages load - // cleanly; we keep a small floor to catch the truly degenerate cases - // before they bad_alloc terminate the device. Heavy pages (long words, - // many style switches) that peak above 8 KB will refuse rather than - // crash -- user sees "Page load error" and can disconnect BT to read. - constexpr uint32_t PAGE_LOAD_MIN_MAX_ALLOC = 8000; + // CrumBLE 4.4 post-bisect: page-load floor lowered to 1.5 KB. + // TextBlock::deserialize now allocates ONE compact data block per + // TextBlock (~100-500 bytes each) instead of the old per-vector + // pattern (which peaked at 5-8 KB contiguous). Empirically the new + // pattern succeeds even at MaxAlloc=2036 bytes under post-BT pressure + // (validated across 5 page-turns in the Option I test). The 8 KB + // floor was set for the old peak and now refuses loads that would + // actually succeed; with 2-bit atlas + post-BT pressure, MaxAlloc + // typically lands at ~5-7 KB which was below the legacy gate. Floor + // stays at 1500 to catch truly degenerate cases (largest single + // TextBlock compact block) before they bad_alloc. + constexpr uint32_t PAGE_LOAD_MIN_MAX_ALLOC = 1500; // CrumBLE 4.3 option 3: opportunistic re-acquire of the reserve when it // was released earlier (BT enable path, or a prior page load that didn't @@ -1296,7 +1327,7 @@ const EpdFontData* Section::embeddedFontDataForStyle(uint8_t styleId) const { // The GlyphEntry::bitmapOffset values are byte offsets into the shared // payload that comes last; this loader reads them in order and resolves // pointers when callers ask for them via glyphAtlasBitmapPtr(). -bool Section::tryInstallGlyphAtlas(uint32_t cpfontContentHash) { +bool Section::tryInstallGlyphAtlas(uint32_t cpfontContentHash, bool preferLowBitDepth) { // Clear any prior install state -- both successful (re-install) and // failed (partial) paths. glyphAtlasInstalled_ = false; @@ -1311,23 +1342,46 @@ bool Section::tryInstallGlyphAtlas(uint32_t cpfontContentHash) { std::vector().swap(glyphAtlasBitmap_); glyphAtlasBitDepth_ = 0; - if (!hasGlyphAtlas()) return false; - if (glyphAtlasCpfontHash_ != cpfontContentHash) { + // CrumBLE 4.4 v41: dual-slot atlas selection. Primary slot is the + // BT-friendly bake (1-bit on v41; can be either depth on legacy v40); + // alt slot is the BT-cold upgrade (2-bit on v41 ≥16pt bakes; empty + // otherwise). preferLowBitDepth comes from the caller's BT-enabled + // check -- when true we install primary; when false (BT cold) we + // prefer alt and fall through to primary if alt is empty. + uint32_t chosenOffset = 0; + uint32_t chosenSize = 0; + uint32_t chosenHash = 0; + const char* chosenLabel = "(none)"; + if (!preferLowBitDepth && hasGlyphAtlasAlt()) { + chosenOffset = glyphAtlasAltOffset_; + chosenSize = glyphAtlasAltSize_; + chosenHash = glyphAtlasAltCpfontHash_; + chosenLabel = "alt (2-bit, BT-cold)"; + } else if (hasGlyphAtlas()) { + chosenOffset = glyphAtlasOffset_; + chosenSize = glyphAtlasSize_; + chosenHash = glyphAtlasCpfontHash_; + chosenLabel = preferLowBitDepth ? "primary (BT-enabled)" : "primary (no alt available)"; + } else { + return false; + } + if (chosenHash != cpfontContentHash) { LOG_INF("SCT", - "Glyph atlas hash mismatch: section baked against 0x%08x, loaded SD font is 0x%08x -- " + "Glyph atlas hash mismatch on %s slot: section baked against 0x%08x, loaded SD font is 0x%08x -- " "falling back to v39 subset / miss-handler", - glyphAtlasCpfontHash_, cpfontContentHash); + chosenLabel, chosenHash, cpfontContentHash); return false; } if (!Storage.openFileForRead("SCT", activeFilePath, file)) { LOG_ERR("SCT", "Glyph atlas install: cannot open %s for read", activeFilePath.c_str()); return false; } - if (!file.seek(glyphAtlasOffset_)) { - LOG_ERR("SCT", "Glyph atlas install: seek to offset %u failed", glyphAtlasOffset_); + if (!file.seek(chosenOffset)) { + LOG_ERR("SCT", "Glyph atlas install: seek to %s offset %u failed", chosenLabel, chosenOffset); file.close(); return false; } + (void)chosenSize; // currently unused -- size validation lives inside BlockHeader read glyphatlas::BlockHeader hdr{}; static_assert(sizeof(hdr) == 12, "GlyphAtlas BlockHeader size drift"); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index d6027fa0..4ba0deba 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -72,6 +72,23 @@ class Section { uint32_t glyphAtlasSize_ = 0; uint32_t glyphAtlasCpfontHash_ = 0; + // CrumBLE 4.4 v41: alternate glyph atlas slot. The primary slot above is + // baked at the bit-depth chosen by --atlas-bit-depth or by the prebake's + // auto-pick (1-bit for <16pt, 2-bit for ≥16pt). The alternate slot holds + // the OTHER bit-depth for the same set of glyphs, so the reader can pick: + // + // * BT cold -> install the 2-bit atlas (better visual) + // * BT enabled -> install the 1-bit atlas (smaller, fits tight heap) + // + // Either slot may be zero when the bake produced only one bit-depth (e.g. + // small-size bake where 2-bit visual gain is not worth the section-file + // bloat). The runtime install path falls back gracefully: prefer the slot + // matching the BT-state preference, else use whichever slot is non-zero. + // v40 files leave these at 0; their single atlas lives in the primary slot. + uint32_t glyphAtlasAltOffset_ = 0; + uint32_t glyphAtlasAltSize_ = 0; + uint32_t glyphAtlasAltCpfontHash_ = 0; + // CrumBLE 4.4: parsed in-RAM glyph atlas for one style. Populated by // tryInstallGlyphAtlas() when the section carries an atlas block and // its cpfontContentHash matches the active SdCardFont. The `entries` @@ -164,7 +181,7 @@ class Section { // the lifetime of the install. void synthesizeAtlasFontData(GlyphAtlasSlot& slot); - bool writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, + bool writeSectionFileHeader(int fontId, float lineCompression, uint8_t extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool bionicReadingEnabled, bool guideReadingEnabled); @@ -175,7 +192,7 @@ class Section { // mismatch / parse failure, returns false WITHOUT calling clearCache -- // the caller (loadSectionFile) decides whether the live cache should be // cleared, so we never accidentally delete the prebake fallback. - bool tryLoadFromPath(const std::string& path, int fontId, float lineCompression, bool extraParagraphSpacing, + bool tryLoadFromPath(const std::string& path, int fontId, float lineCompression, uint8_t extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool bionicReadingEnabled, bool guideReadingEnabled); @@ -197,12 +214,12 @@ class Section { // file is tried first, then sections-prebake/ as a read-only fallback. // Default-false preserves call-site compatibility for any caller that // hasn't been updated to thread the SETTINGS toggle through. - bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, + bool loadSectionFile(int fontId, float lineCompression, uint8_t extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool bionicReadingEnabled, bool guideReadingEnabled, bool prebakeFallbackEnabled = false); bool clearCache() const; - bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, bool forceParagraphIndents, + bool createSectionFile(int fontId, float lineCompression, uint8_t extraParagraphSpacing, bool forceParagraphIndents, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering, bool bionicReadingEnabled, bool guideReadingEnabled, const std::function& popupFn = nullptr, @@ -242,6 +259,12 @@ class Section { uint32_t glyphAtlasOffset() const { return glyphAtlasOffset_; } uint32_t glyphAtlasSize() const { return glyphAtlasSize_; } uint32_t glyphAtlasCpfontHash() const { return glyphAtlasCpfontHash_; } + // v41: alternate atlas slot (the OTHER bit-depth, when the bake emitted + // both). See field comment above for the BT-aware install rationale. + bool hasGlyphAtlasAlt() const { return glyphAtlasAltOffset_ != 0 && glyphAtlasAltSize_ != 0; } + uint32_t glyphAtlasAltOffset() const { return glyphAtlasAltOffset_; } + uint32_t glyphAtlasAltSize() const { return glyphAtlasAltSize_; } + uint32_t glyphAtlasAltCpfontHash() const { return glyphAtlasAltCpfontHash_; } // CrumBLE 4.4: read the glyph atlas block from the section file and // populate glyphAtlasSlots_ + glyphAtlasBitmap_. Validates against @@ -251,7 +274,12 @@ class Section { // or SD-font miss handler path. Returns true iff at least one style // slot was populated. Idempotent: a second call with the same hash // re-installs cleanly. - bool tryInstallGlyphAtlas(uint32_t cpfontContentHash); + // CrumBLE 4.4 v41: when preferLowBitDepth=true (typical caller: reader + // with BT enabled), install the primary (1-bit) slot. When false (BT + // cold), install the alt (2-bit) slot if non-zero, else fall through + // to the primary. Pre-v41 sections only have the primary slot, so the + // behavior is unchanged for old prebakes. + bool tryInstallGlyphAtlas(uint32_t cpfontContentHash, bool preferLowBitDepth = false); // True after a successful tryInstallGlyphAtlas(); false otherwise. bool glyphAtlasInstalled() const { return glyphAtlasInstalled_; } diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 7f544670..4c9d6a2c 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -1,5 +1,6 @@ #include "TextBlock.h" +#include // ESP.getMaxAllocHeap() for deserialize pre-flight #include #include #include @@ -18,19 +19,18 @@ constexpr uint32_t SERIALIZED_MIN_WORD_METADATA_BYTES = constexpr uint32_t SERIALIZED_POST_WORD_MIN_METADATA_BYTES = sizeof(int16_t) + sizeof(EpdFontFamily::Style) + sizeof(uint8_t); -uint16_t measureBackgroundWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, +uint16_t measureBackgroundWidth(const GfxRenderer& renderer, const int fontId, std::string_view word, const EpdFontFamily::Style style) { if (word.size() == 1 && word[0] == ' ') { return renderer.getSpaceWidth(fontId, style); } - return static_cast(std::max(0, renderer.getTextAdvanceX(fontId, word.c_str(), style))); + return static_cast(std::max(0, renderer.getTextAdvanceX(fontId, std::string(word).c_str(), style))); } -bool isWhitespaceOnlyBackgroundToken(const std::string& word) { +bool isWhitespaceOnlyBackgroundToken(std::string_view word) { if (word.empty()) { return false; } - for (size_t i = 0; i < word.size();) { const auto c = static_cast(word[i]); if (c == ' ' || c == '\r' || c == '\n' || c == '\t') { @@ -41,14 +41,13 @@ bool isWhitespaceOnlyBackgroundToken(const std::string& word) { i += 2; continue; } - if (c == 0xE2 && i + 2 < word.size() && static_cast(word[i + 1]) == 0x80 && - static_cast(word[i + 2]) == 0xAF) { + if (c == 0xE2 && i + 2 < word.size() && static_cast(word[i + 2]) == 0xAF && + static_cast(word[i + 1]) == 0x80) { i += 3; continue; } return false; } - return true; } @@ -62,22 +61,20 @@ bool readBoundedString(FsFile& file, std::string& s) { LOG_ERR("TXB", "Deserialization failed: word length %lu exceeds maximum", static_cast(len)); return false; } - const int remaining = file.available(); if (remaining < 0 || static_cast(remaining) < len) { LOG_ERR("TXB", "Deserialization failed: truncated word payload (%lu bytes requested, %d available)", static_cast(len), remaining); return false; } - if (len == 0) { s.clear(); return true; } - s.resize(len); if (file.read(&s[0], len) != static_cast(len)) { - LOG_ERR("TXB", "Deserialization failed: could not read %lu-byte word payload", static_cast(len)); + LOG_ERR("TXB", "Deserialization failed: could not read %lu-byte word payload", + static_cast(len)); return false; } return true; @@ -85,193 +82,279 @@ bool readBoundedString(FsFile& file, std::string& s) { } // namespace -void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { - // Validate iterator bounds before rendering - const bool hasBionic = !wordBionicBoundary.empty(); - const bool hasGuideDots = !wordGuideDotXOffset.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - words.size() != wordBackgroundBlack.size() || - (hasBionic && (words.size() != wordBionicBoundary.size() || words.size() != wordBionicSuffixX.size())) || - (!hasBionic && !wordBionicSuffixX.empty()) || (hasGuideDots && words.size() != wordGuideDotXOffset.size())) { - LOG_ERR("TXB", - "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, dotX=%u, bg=%u)\n", - (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), - (uint32_t)wordBionicBoundary.size(), (uint32_t)wordBionicSuffixX.size(), - (uint32_t)wordGuideDotXOffset.size(), (uint32_t)wordBackgroundBlack.size()); +uint32_t TextBlock::computeLayout(uint16_t wordCount, uint32_t wordContentBytes, bool hasBionic, bool hasGuideDots, + uint32_t& outOffWordOffsets, uint32_t& outOffWordXpos, uint32_t& outOffWordStyles, + uint32_t& outOffWordBackgroundBlack, uint32_t& outOffWordBionicBoundary, + uint32_t& outOffWordBionicSuffixX, uint32_t& outOffWordGuideDotXOffset, + uint32_t& outOffWordContents) { + // Layout (highest alignment first to avoid padding): + // wordOffsets[wordCount+1] uint32_t, 4-byte aligned + // wordXpos[wordCount] int16_t, 2-byte + // wordBionicSuffixX[wc] uint16_t, 2-byte (if hasBionic) + // wordGuideDotXOffset[wc] uint16_t, 2-byte (if hasGuideDots) + // wordStyles[wordCount] uint8_t, 1-byte + // wordBackgroundBlack[wc] uint8_t, 1-byte + // wordBionicBoundary[wc] uint8_t, 1-byte (if hasBionic) + // wordContents char[], 1-byte (packed null-terminated) + uint32_t offset = 0; + outOffWordOffsets = offset; + offset += (static_cast(wordCount) + 1) * sizeof(uint32_t); + outOffWordXpos = offset; + offset += static_cast(wordCount) * sizeof(int16_t); + if (hasBionic) { + outOffWordBionicSuffixX = offset; + offset += static_cast(wordCount) * sizeof(uint16_t); + } else { + outOffWordBionicSuffixX = 0; + } + if (hasGuideDots) { + outOffWordGuideDotXOffset = offset; + offset += static_cast(wordCount) * sizeof(uint16_t); + } else { + outOffWordGuideDotXOffset = 0; + } + outOffWordStyles = offset; + offset += static_cast(wordCount) * sizeof(EpdFontFamily::Style); + outOffWordBackgroundBlack = offset; + offset += static_cast(wordCount) * sizeof(uint8_t); + if (hasBionic) { + outOffWordBionicBoundary = offset; + offset += static_cast(wordCount) * sizeof(uint8_t); + } else { + outOffWordBionicBoundary = 0; + } + outOffWordContents = offset; + offset += wordContentBytes; + return offset; +} + +TextBlock::TextBlock(std::vector words, std::vector word_xpos, + std::vector word_styles, std::vector bionic_boundary, + std::vector bionic_suffix_x, std::vector guide_dot_x_offset, + std::vector background_black, const BlockStyle& blockStyle) { + blockStyle_ = blockStyle; + const uint16_t wc = static_cast(words.size()); + const bool hasBionic = !bionic_boundary.empty(); + const bool hasGuideDots = !guide_dot_x_offset.empty(); + + // Size-mismatch check. If invalid, construct empty block (wordCount_ stays 0). + if (word_xpos.size() != wc || word_styles.size() != wc || background_black.size() != wc || + (hasBionic && (bionic_boundary.size() != wc || bionic_suffix_x.size() != wc)) || + (!hasBionic && !bionic_suffix_x.empty()) || (hasGuideDots && guide_dot_x_offset.size() != wc)) { + LOG_ERR("TXB", "Construction skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, dotX=%u, bg=%u)", + wc, static_cast(word_xpos.size()), static_cast(word_styles.size()), + static_cast(bionic_boundary.size()), static_cast(bionic_suffix_x.size()), + static_cast(guide_dot_x_offset.size()), static_cast(background_black.size())); return; } - for (size_t i = 0; i < words.size(); i++) { - const int wordX = wordXpos[i] + x; - const EpdFontFamily::Style currentStyle = wordStyles[i]; - const uint8_t boundary = hasBionic ? wordBionicBoundary[i] : 0; + uint32_t wordContentBytes = 0; + for (const auto& w : words) { + wordContentBytes += static_cast(w.size()) + 1; // +1 for null terminator + } + + uint32_t offWordOffsets, offWordXpos, offWordStyles, offWordBackgroundBlack; + uint32_t offWordBionicBoundary, offWordBionicSuffixX, offWordGuideDotXOffset, offWordContents; + const uint32_t totalBytes = + computeLayout(wc, wordContentBytes, hasBionic, hasGuideDots, offWordOffsets, offWordXpos, offWordStyles, + offWordBackgroundBlack, offWordBionicBoundary, offWordBionicSuffixX, offWordGuideDotXOffset, + offWordContents); - if (wordBackgroundBlack[i] != 0 && isWhitespaceOnlyBackgroundToken(words[i])) { - const uint16_t backgroundWidth = measureBackgroundWidth(renderer, fontId, words[i], currentStyle); + auto block = std::unique_ptr(new (std::nothrow) uint8_t[totalBytes]); + if (!block) { + LOG_ERR("TXB", "Construction OOM: failed to alloc %u-byte data block (wc=%u, contentBytes=%u)", totalBytes, wc, + wordContentBytes); + return; + } + + uint8_t* base = block.get(); + auto* wordOffsetsPtr = reinterpret_cast(base + offWordOffsets); + auto* wordXposPtr = reinterpret_cast(base + offWordXpos); + auto* wordStylesPtr = reinterpret_cast(base + offWordStyles); + auto* wordBackgroundBlackPtr = reinterpret_cast(base + offWordBackgroundBlack); + uint8_t* wordBionicBoundaryPtr = hasBionic ? reinterpret_cast(base + offWordBionicBoundary) : nullptr; + uint16_t* wordBionicSuffixXPtr = hasBionic ? reinterpret_cast(base + offWordBionicSuffixX) : nullptr; + uint16_t* wordGuideDotXOffsetPtr = + hasGuideDots ? reinterpret_cast(base + offWordGuideDotXOffset) : nullptr; + char* wordContentsPtr = reinterpret_cast(base + offWordContents); + + uint32_t curOffset = 0; + for (uint16_t i = 0; i < wc; ++i) { + wordOffsetsPtr[i] = curOffset; + const auto& w = words[i]; + if (!w.empty()) { + std::memcpy(wordContentsPtr + curOffset, w.data(), w.size()); + } + curOffset += static_cast(w.size()); + wordContentsPtr[curOffset++] = '\0'; + } + wordOffsetsPtr[wc] = curOffset; + + for (uint16_t i = 0; i < wc; ++i) wordXposPtr[i] = word_xpos[i]; + for (uint16_t i = 0; i < wc; ++i) wordStylesPtr[i] = word_styles[i]; + for (uint16_t i = 0; i < wc; ++i) wordBackgroundBlackPtr[i] = background_black[i]; + if (hasBionic) { + for (uint16_t i = 0; i < wc; ++i) wordBionicBoundaryPtr[i] = bionic_boundary[i]; + for (uint16_t i = 0; i < wc; ++i) wordBionicSuffixXPtr[i] = bionic_suffix_x[i]; + } + if (hasGuideDots) { + for (uint16_t i = 0; i < wc; ++i) wordGuideDotXOffsetPtr[i] = guide_dot_x_offset[i]; + } + + dataBlock_ = std::move(block); + dataBlockSize_ = totalBytes; + wordCount_ = wc; + wordOffsets_ = wordOffsetsPtr; + wordContents_ = wordContentsPtr; + wordXpos_ = wordXposPtr; + wordStyles_ = wordStylesPtr; + wordBackgroundBlack_ = wordBackgroundBlackPtr; + wordBionicBoundary_ = wordBionicBoundaryPtr; + wordBionicSuffixX_ = wordBionicSuffixXPtr; + wordGuideDotXOffset_ = wordGuideDotXOffsetPtr; +} + +void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y, + const bool foregroundBlack) const { + if (wordCount_ == 0 || !dataBlock_) return; + const bool hasBionic = wordBionicBoundary_ != nullptr; + const bool hasGuideDots = wordGuideDotXOffset_ != nullptr; + const WordsView words = getWords(); + + for (uint16_t i = 0; i < wordCount_; ++i) { + const int wordX = wordXpos_[i] + x; + const EpdFontFamily::Style currentStyle = wordStyles_[i]; + const uint8_t boundary = hasBionic ? wordBionicBoundary_[i] : 0; + const WordView word = words[i]; + + if (wordBackgroundBlack_[i] != 0 && isWhitespaceOnlyBackgroundToken(word)) { + const uint16_t backgroundWidth = measureBackgroundWidth(renderer, fontId, word, currentStyle); if (backgroundWidth > 0) { - renderer.fillRect(wordX, y, backgroundWidth, renderer.getFontAscenderSize(fontId), true); + renderer.fillRect(wordX, y, backgroundWidth, renderer.getFontAscenderSize(fontId), foregroundBlack); } } if (boundary > 0) { - // Bionic split: draw bold prefix (max 9 codepoints = 36 UTF-8 bytes + null). - // suffixX is pre-computed at cache creation time to avoid font metric lookups at render time. const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); char boldBuf[40]; - const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); - memcpy(boldBuf, words[i].c_str(), boldLen); + const size_t boldLen = std::min({static_cast(boundary), word.size(), sizeof(boldBuf) - 1}); + std::memcpy(boldBuf, word.c_str(), boldLen); boldBuf[boldLen] = '\0'; - renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle); - const int suffixX = wordX + wordBionicSuffixX[i]; - renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle); + renderer.drawText(fontId, wordX, y, boldBuf, foregroundBlack, boldStyle); + const int suffixX = wordX + wordBionicSuffixX_[i]; + renderer.drawText(fontId, suffixX, y, word.c_str() + boldLen, foregroundBlack, currentStyle); } else { - renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle); + renderer.drawText(fontId, wordX, y, word.c_str(), foregroundBlack, currentStyle); } - if (hasGuideDots && wordGuideDotXOffset[i] > 0) { - renderer.drawText(fontId, wordX + wordGuideDotXOffset[i], y, "\xc2\xb7", true, EpdFontFamily::REGULAR); + if (hasGuideDots && wordGuideDotXOffset_[i] > 0) { + renderer.drawText(fontId, wordX + wordGuideDotXOffset_[i], y, "\xc2\xb7", foregroundBlack, EpdFontFamily::REGULAR); } if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) { - const std::string& w = words[i]; - const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle); - // y is the top of the text line; add ascender to reach baseline, then offset 2px below + const int fullWordWidth = renderer.getTextWidth(fontId, word.c_str(), currentStyle); const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2; - int startX = wordX; int underlineWidth = fullWordWidth; - - // if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visiblePtr = w.c_str() + 3; + if (word.size() >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83) { + const char* visiblePtr = word.c_str() + 3; const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); startX = wordX + prefixWidth; underlineWidth = visibleWidth; } - - renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, 3, true); + renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, 3, foregroundBlack); } if ((currentStyle & EpdFontFamily::STRIKETHROUGH) != 0) { - const std::string& w = words[i]; - const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle); - // Position at roughly mid-glyph height. Offset down from the half-ascender - // point to align with the visual centre of lowercase letters. - // Added a 6 pixel offset after testing on various fonts to improve the visual alignment of the strike-through - // line. + const int fullWordWidth = renderer.getTextWidth(fontId, word.c_str(), currentStyle); const int strikeY = y + renderer.getFontAscenderSize(fontId) / 2 + 6; - int startX = wordX; int strikeWidth = fullWordWidth; - - // Skip em-space prefix same as underline does - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visiblePtr = w.c_str() + 3; + if (word.size() >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83) { + const char* visiblePtr = word.c_str() + 3; const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); startX = wordX + prefixWidth; strikeWidth = visibleWidth; } - - renderer.drawLine(startX, strikeY, startX + strikeWidth, strikeY, 3, true); + renderer.drawLine(startX, strikeY, startX + strikeWidth, strikeY, 3, foregroundBlack); } } } bool TextBlock::serialize(FsFile& file) const { - const bool hasBionic = !wordBionicBoundary.empty(); - const bool hasGuideDots = !wordGuideDotXOffset.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - words.size() != wordBackgroundBlack.size() || - (hasBionic && (words.size() != wordBionicBoundary.size() || words.size() != wordBionicSuffixX.size())) || - (!hasBionic && !wordBionicSuffixX.empty()) || (hasGuideDots && words.size() != wordGuideDotXOffset.size())) { - LOG_ERR( - "TXB", - "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u, dotX=%u, bg=%u)\n", - static_cast(words.size()), static_cast(wordXpos.size()), - static_cast(wordStyles.size()), static_cast(wordBionicBoundary.size()), - static_cast(wordBionicSuffixX.size()), static_cast(wordGuideDotXOffset.size()), - static_cast(wordBackgroundBlack.size())); - return false; - } + const bool hasBionic = wordBionicBoundary_ != nullptr; + const bool hasGuideDots = wordGuideDotXOffset_ != nullptr; - // Word data - if (!serialization::tryWritePod(file, static_cast(words.size()))) { + if (!serialization::tryWritePod(file, wordCount_)) { LOG_ERR("TXB", "Serialization failed: could not write word count"); return false; } - for (const auto& w : words) { - if (!serialization::tryWriteString(file, w)) { + const WordsView words = getWords(); + for (uint16_t i = 0; i < wordCount_; ++i) { + const WordView w = words[i]; + if (!serialization::tryWritePod(file, static_cast(w.size()))) return false; + if (w.size() > 0 && file.write(reinterpret_cast(w.data()), w.size()) != static_cast(w.size())) { LOG_ERR("TXB", "Serialization failed: could not write word payload"); return false; } } - for (auto x : wordXpos) { - if (!serialization::tryWritePod(file, x)) return false; + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordXpos_[i])) return false; } - for (auto s : wordStyles) { - if (!serialization::tryWritePod(file, s)) return false; - } - if (!serialization::tryWritePod(file, static_cast(hasBionic ? 1 : 0))) { - return false; + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordStyles_[i])) return false; } + if (!serialization::tryWritePod(file, static_cast(hasBionic ? 1 : 0))) return false; if (hasBionic) { - for (auto b : wordBionicBoundary) { - if (!serialization::tryWritePod(file, b)) return false; + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordBionicBoundary_[i])) return false; } - for (auto sx : wordBionicSuffixX) { - if (!serialization::tryWritePod(file, sx)) return false; + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordBionicSuffixX_[i])) return false; } } - if (!serialization::tryWritePod(file, static_cast(hasGuideDots ? 1 : 0))) { - return false; - } + if (!serialization::tryWritePod(file, static_cast(hasGuideDots ? 1 : 0))) return false; if (hasGuideDots) { - for (auto dx : wordGuideDotXOffset) { - if (!serialization::tryWritePod(file, dx)) return false; + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordGuideDotXOffset_[i])) return false; } } - for (auto bg : wordBackgroundBlack) { - if (!serialization::tryWritePod(file, bg)) return false; - } - - // Style (alignment + margins/padding/indent) - return serialization::tryWritePod(file, blockStyle.alignment) && - serialization::tryWritePod(file, blockStyle.textAlignDefined) && - serialization::tryWritePod(file, blockStyle.marginTop) && - serialization::tryWritePod(file, blockStyle.marginBottom) && - serialization::tryWritePod(file, blockStyle.marginLeft) && - serialization::tryWritePod(file, blockStyle.marginRight) && - serialization::tryWritePod(file, blockStyle.paddingTop) && - serialization::tryWritePod(file, blockStyle.paddingBottom) && - serialization::tryWritePod(file, blockStyle.paddingLeft) && - serialization::tryWritePod(file, blockStyle.paddingRight) && - serialization::tryWritePod(file, blockStyle.textIndent) && - serialization::tryWritePod(file, blockStyle.textIndentDefined); + for (uint16_t i = 0; i < wordCount_; ++i) { + if (!serialization::tryWritePod(file, wordBackgroundBlack_[i])) return false; + } + + return serialization::tryWritePod(file, blockStyle_.alignment) && + serialization::tryWritePod(file, blockStyle_.textAlignDefined) && + serialization::tryWritePod(file, blockStyle_.marginTop) && + serialization::tryWritePod(file, blockStyle_.marginBottom) && + serialization::tryWritePod(file, blockStyle_.marginLeft) && + serialization::tryWritePod(file, blockStyle_.marginRight) && + serialization::tryWritePod(file, blockStyle_.paddingTop) && + serialization::tryWritePod(file, blockStyle_.paddingBottom) && + serialization::tryWritePod(file, blockStyle_.paddingLeft) && + serialization::tryWritePod(file, blockStyle_.paddingRight) && + serialization::tryWritePod(file, blockStyle_.textIndent) && + serialization::tryWritePod(file, blockStyle_.textIndentDefined); } +// CrumBLE 4.4 post-bisect: deserialize allocates ONE compact data block +// and streams the on-disk fields directly into it. Two-pass: +// Pass 1: scan word length prefixes (skipping content bytes) to compute +// total content bytes + locate hasBionic / hasGuideDots flags. +// Pass 2: seek back, allocate the compact block sized exactly to the +// computed layout, stream fields into the block. +// No temporary std::vectors. Peak allocation per TextBlock collapses from +// 7+ small vector allocs + N per-word std::string allocs to ONE alloc. std::unique_ptr TextBlock::deserialize(FsFile& file) { uint16_t wc; - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - std::vector wordBionicBoundary; - std::vector wordBionicSuffixX; - std::vector wordGuideDotXOffset; - std::vector wordBackgroundBlack; - BlockStyle blockStyle; - - // Word count if (!serialization::tryReadPod(file, wc)) { LOG_ERR("TXB", "Deserialization failed: could not read word count"); return nullptr; } - - // A TextBlock is one rendered line of text, so counts far above a few hundred are not legitimate. - // Clamp aggressively here so corrupted cache data cannot trigger huge STL allocations on the ESP32-C3. if (wc > MAX_WORDS_PER_TEXT_BLOCK) { LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc); return nullptr; @@ -286,63 +369,129 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { return nullptr; } - // Word data - words.resize(wc); - wordXpos.resize(wc); - wordStyles.resize(wc); - wordBackgroundBlack.resize(wc); - for (auto& w : words) { - if (!readBoundedString(file, w)) { + // ---- Pass 1: scan word lengths + locate flags without consuming bytes ---- + const uint32_t wordSectionStart = file.position(); + uint32_t totalContentBytes = 0; + for (uint16_t i = 0; i < wc; ++i) { + uint32_t len = 0; + if (!serialization::tryReadPod(file, len)) { + LOG_ERR("TXB", "Deserialization failed: could not read word %u length prefix", i); + return nullptr; + } + if (len > MAX_SERIALIZED_WORD_BYTES) { + LOG_ERR("TXB", "Deserialization failed: word %u length %lu exceeds maximum", i, static_cast(len)); + return nullptr; + } + totalContentBytes += len + 1; // +1 for null terminator we add in-block + // Skip the content bytes without reading them. + if (len > 0 && !file.seek(file.position() + len)) { + LOG_ERR("TXB", "Deserialization failed: could not seek past word %u content", i); return nullptr; } } - - const uint32_t remainingMetadataBytes = static_cast(wc) * SERIALIZED_POST_WORD_MIN_METADATA_BYTES + - sizeof(uint8_t) + sizeof(uint8_t) + SERIALIZED_TEXT_BLOCK_TAIL_BYTES; - const int remainingAfterWords = file.available(); - if (remainingAfterWords < 0 || static_cast(remainingAfterWords) < remainingMetadataBytes) { - LOG_ERR("TXB", "Deserialization failed: truncated post-word metadata (%lu bytes needed, %d available)", - static_cast(remainingMetadataBytes), remainingAfterWords); + // Skip wordXpos + wordStyles + if (!file.seek(file.position() + static_cast(wc) * sizeof(int16_t) + + static_cast(wc) * sizeof(EpdFontFamily::Style))) { return nullptr; } - - for (auto& x : wordXpos) { - if (!serialization::tryReadPod(file, x)) return nullptr; - } - for (auto& s : wordStyles) { - if (!serialization::tryReadPod(file, s)) return nullptr; - } - uint8_t hasBionic = 0; - if (!serialization::tryReadPod(file, hasBionic) || hasBionic > 1) { + uint8_t hasBionicByte = 0; + if (!serialization::tryReadPod(file, hasBionicByte) || hasBionicByte > 1) { LOG_ERR("TXB", "Deserialization failed: invalid bionic metadata flag"); return nullptr; } + const bool hasBionic = (hasBionicByte == 1); if (hasBionic) { - wordBionicBoundary.resize(wc); - wordBionicSuffixX.resize(wc); - for (auto& b : wordBionicBoundary) { - if (!serialization::tryReadPod(file, b)) return nullptr; - } - for (auto& sx : wordBionicSuffixX) { - if (!serialization::tryReadPod(file, sx)) return nullptr; + if (!file.seek(file.position() + static_cast(wc) * sizeof(uint8_t) + + static_cast(wc) * sizeof(uint16_t))) { + return nullptr; } } - uint8_t hasGuideDots = 0; - if (!serialization::tryReadPod(file, hasGuideDots) || hasGuideDots > 1) { + uint8_t hasGuideDotsByte = 0; + if (!serialization::tryReadPod(file, hasGuideDotsByte) || hasGuideDotsByte > 1) { LOG_ERR("TXB", "Deserialization failed: invalid guide-dot metadata flag"); return nullptr; } + const bool hasGuideDots = (hasGuideDotsByte == 1); + + // ---- Pass 2: compute layout, allocate single block, stream into it ---- + uint32_t offWordOffsets, offWordXpos, offWordStyles, offWordBackgroundBlack; + uint32_t offWordBionicBoundary, offWordBionicSuffixX, offWordGuideDotXOffset, offWordContents; + const uint32_t totalBytes = + computeLayout(wc, totalContentBytes, hasBionic, hasGuideDots, offWordOffsets, offWordXpos, offWordStyles, + offWordBackgroundBlack, offWordBionicBoundary, offWordBionicSuffixX, offWordGuideDotXOffset, + offWordContents); + + // Pre-flight: skip the alloc entirely if MaxAlloc can't cover the block. + // 128 byte margin for allocator metadata overhead. + if (ESP.getMaxAllocHeap() < totalBytes + 128) { + LOG_ERR("TXB", "Refusing dataBlock alloc(%u): maxAlloc=%u < needed=%u (wc=%u)", totalBytes, + ESP.getMaxAllocHeap(), totalBytes + 128, wc); + return nullptr; + } + auto block = std::unique_ptr(new (std::nothrow) uint8_t[totalBytes]); + if (!block) { + LOG_ERR("TXB", "dataBlock alloc(%u) returned nullptr", totalBytes); + return nullptr; + } + uint8_t* base = block.get(); + auto* wordOffsetsPtr = reinterpret_cast(base + offWordOffsets); + auto* wordXposPtr = reinterpret_cast(base + offWordXpos); + auto* wordStylesPtr = reinterpret_cast(base + offWordStyles); + auto* wordBackgroundBlackPtr = reinterpret_cast(base + offWordBackgroundBlack); + uint8_t* wordBionicBoundaryPtr = hasBionic ? reinterpret_cast(base + offWordBionicBoundary) : nullptr; + uint16_t* wordBionicSuffixXPtr = hasBionic ? reinterpret_cast(base + offWordBionicSuffixX) : nullptr; + uint16_t* wordGuideDotXOffsetPtr = + hasGuideDots ? reinterpret_cast(base + offWordGuideDotXOffset) : nullptr; + char* wordContentsPtr = reinterpret_cast(base + offWordContents); + + // Seek back to the word section and stream-read into block. + if (!file.seek(wordSectionStart)) { + LOG_ERR("TXB", "Deserialization failed: could not seek back to word section"); + return nullptr; + } + uint32_t curOffset = 0; + for (uint16_t i = 0; i < wc; ++i) { + uint32_t len = 0; + if (!serialization::tryReadPod(file, len)) return nullptr; + wordOffsetsPtr[i] = curOffset; + if (len > 0) { + if (file.read(reinterpret_cast(wordContentsPtr) + curOffset, len) != static_cast(len)) { + LOG_ERR("TXB", "Deserialization failed: could not stream-read word %u content", i); + return nullptr; + } + curOffset += len; + } + wordContentsPtr[curOffset++] = '\0'; + } + wordOffsetsPtr[wc] = curOffset; + + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordXposPtr[i])) return nullptr; + } + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordStylesPtr[i])) return nullptr; + } + uint8_t skipFlag = 0; + if (!serialization::tryReadPod(file, skipFlag)) return nullptr; // hasBionic, already known + if (hasBionic) { + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordBionicBoundaryPtr[i])) return nullptr; + } + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordBionicSuffixXPtr[i])) return nullptr; + } + } + if (!serialization::tryReadPod(file, skipFlag)) return nullptr; // hasGuideDots, already known if (hasGuideDots) { - wordGuideDotXOffset.resize(wc); - for (auto& dx : wordGuideDotXOffset) { - if (!serialization::tryReadPod(file, dx)) return nullptr; + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordGuideDotXOffsetPtr[i])) return nullptr; } } - for (auto& bg : wordBackgroundBlack) { - if (!serialization::tryReadPod(file, bg)) return nullptr; + for (uint16_t i = 0; i < wc; ++i) { + if (!serialization::tryReadPod(file, wordBackgroundBlackPtr[i])) return nullptr; } - // Style (alignment + margins/padding/indent) + BlockStyle blockStyle; if (!serialization::tryReadPod(file, blockStyle.alignment) || !serialization::tryReadPod(file, blockStyle.textAlignDefined) || !serialization::tryReadPod(file, blockStyle.marginTop) || @@ -359,13 +508,13 @@ std::unique_ptr TextBlock::deserialize(FsFile& file) { return nullptr; } - auto* textBlock = new (std::nothrow) TextBlock( - std::move(words), std::move(wordXpos), std::move(wordStyles), std::move(wordBionicBoundary), - std::move(wordBionicSuffixX), std::move(wordGuideDotXOffset), std::move(wordBackgroundBlack), blockStyle); + auto* textBlock = new (std::nothrow) TextBlock(std::move(block), totalBytes, wc, wordOffsetsPtr, wordContentsPtr, + wordXposPtr, wordStylesPtr, wordBionicBoundaryPtr, + wordBionicSuffixXPtr, wordGuideDotXOffsetPtr, + wordBackgroundBlackPtr, blockStyle); if (!textBlock) { - LOG_ERR("TXB", "Deserialization failed: could not allocate TextBlock"); + LOG_ERR("TXB", "Deserialization failed: could not allocate TextBlock wrapper"); return nullptr; } - return std::unique_ptr(textBlock); } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index de4acee5..154d7520 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -2,63 +2,168 @@ #include #include +#include #include +#include #include +#include #include #include "Block.h" #include "BlockStyle.h" -// Represents a line of text on a page -class TextBlock final : public Block { +// CrumBLE 4.4 post-bisect: lightweight view types so callers can access +// TextBlock's compact storage without materializing per-word std::strings. +// WordView is a string-like reference into the TextBlock's owned data +// block; WordsView is an indexable + iterable collection of WordView. +// Lifetime: views are valid only while the source TextBlock is alive. +class WordView { + public: + WordView() : data_(""), size_(0) {} + WordView(const char* data, uint16_t size) : data_(data), size_(size) {} + const char* c_str() const { return data_; } + const char* data() const { return data_; } + size_t size() const { return size_; } + bool empty() const { return size_ == 0; } + char operator[](size_t i) const { return data_[i]; } + // Implicit conversion to std::string_view so callers using string_view-aware + // APIs (std::string::operator+=, std::string ctor, etc.) work unchanged. + operator std::string_view() const { return std::string_view(data_, size_); } + + private: + const char* data_; + uint16_t size_; +}; + +class WordsView { + public: + WordsView() : contents_(nullptr), offsets_(nullptr), count_(0) {} + WordsView(const char* contents, const uint32_t* offsets, uint16_t count) + : contents_(contents), offsets_(offsets), count_(count) {} + + WordView operator[](size_t i) const { + if (i >= count_) return WordView(); + const uint32_t start = offsets_[i]; + const uint32_t end = offsets_[i + 1]; + // Strings are stored null-terminated; size excludes the terminator. + const uint16_t len = (end > start) ? static_cast(end - start - 1) : 0; + return WordView(contents_ + start, len); + } + size_t size() const { return count_; } + bool empty() const { return count_ == 0; } + WordView front() const { return (*this)[0]; } + + class iterator { + public: + iterator(const WordsView* v, size_t i) : v_(v), i_(i) {} + WordView operator*() const { return (*v_)[i_]; } + iterator& operator++() { + ++i_; + return *this; + } + bool operator==(const iterator& o) const { return i_ == o.i_; } + bool operator!=(const iterator& o) const { return i_ != o.i_; } + + private: + const WordsView* v_; + size_t i_; + }; + iterator begin() const { return iterator(this, 0); } + iterator end() const { return iterator(this, count_); } + private: - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - // Per-word bionic boundary: N > 0 means the first N bytes of words[i] are rendered bold, - // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). - // Empty when no word in the block has a bionic split. - std::vector wordBionicBoundary; - // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. - // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. - // Empty in lockstep with wordBionicBoundary. - std::vector wordBionicSuffixX; - // Pre-computed pixel offset from word start to the guide dot that follows it. 0 = no dot. - // Eliminates the guide dot as a separate TextBlock entry, saving ~12 bytes per inter-word gap. - // Empty when no word in the block has a guide dot. - std::vector wordGuideDotXOffset; - // 1 when a simple black CSS background should be painted behind this word/token. - std::vector wordBackgroundBlack; - BlockStyle blockStyle; + const char* contents_; + const uint32_t* offsets_; + uint16_t count_; +}; +// Represents a line of text on a page. +// +// CrumBLE 4.4 post-bisect: collapsed 7 std::vector members + N std::string +// content allocations into ONE owned data block. Lays out fixed-size arrays +// first, packs null-terminated word strings at the tail. Cuts per-Page-DOM +// allocation count from ~210+ (30 TextBlocks * 7 vectors + strings) to ~30 +// (one block per TextBlock). On the ESP32-C3 RISC-V heap, the previous +// fragmented pattern was the main bad_alloc trigger under post-NimBLE +// pressure; one alloc per TextBlock is much less likely to fail mid-deserialize. +class TextBlock final : public Block { public: + // Parser path: takes vectors (built up word-by-word during HTML parse), + // copies their contents into a single compact data block. explicit TextBlock(std::vector words, std::vector word_xpos, std::vector word_styles, std::vector bionic_boundary, std::vector bionic_suffix_x, std::vector guide_dot_x_offset, - std::vector background_black, const BlockStyle& blockStyle = BlockStyle()) - : words(std::move(words)), - wordXpos(std::move(word_xpos)), - wordStyles(std::move(word_styles)), - wordBionicBoundary(std::move(bionic_boundary)), - wordBionicSuffixX(std::move(bionic_suffix_x)), - wordGuideDotXOffset(std::move(guide_dot_x_offset)), - wordBackgroundBlack(std::move(background_black)), - blockStyle(blockStyle) {} + std::vector background_black, const BlockStyle& blockStyle = BlockStyle()); + ~TextBlock() override = default; - void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } - const BlockStyle& getBlockStyle() const { return blockStyle; } - const std::vector& getWords() const { return words; } - // CrumBLE (dictionary port from SEEK): expose per-word X positions and - // styles so the word-select overlay can hit-test taps against the - // rendered glyphs and reproduce the bold/italic state when drawing the - // highlight box. Const-ref returns -- callers never modify these. - const std::vector& getWordXpos() const { return wordXpos; } - const std::vector& getWordStyles() const { return wordStyles; } - bool isEmpty() override { return words.empty(); } - size_t wordCount() const { return words.size(); } + + void setBlockStyle(const BlockStyle& blockStyle) { blockStyle_ = blockStyle; } + const BlockStyle& getBlockStyle() const { return blockStyle_; } + WordsView getWords() const { return WordsView(wordContents_, wordOffsets_, wordCount_); } + std::span getWordXpos() const { + return wordXpos_ ? std::span(wordXpos_, wordCount_) : std::span(); + } + std::span getWordStyles() const { + return wordStyles_ ? std::span(wordStyles_, wordCount_) + : std::span(); + } + bool isEmpty() override { return wordCount_ == 0; } + size_t wordCount() const { return wordCount_; } // given a renderer works out where to break the words into lines - void render(const GfxRenderer& renderer, int fontId, int x, int y) const; + void render(const GfxRenderer& renderer, int fontId, int x, int y, bool foregroundBlack = true) const; BlockType getType() override { return TEXT_BLOCK; } bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); + + // Diagnostic: size of the owned compact data block, or 0 if alloc failed. + uint32_t getDataBlockSize() const { return dataBlockSize_; } + + private: + // Internal constructor used by deserialize when it has already allocated + // and populated the compact data block directly from disk. + TextBlock(std::unique_ptr block, uint32_t blockSize, uint16_t wordCount, const uint32_t* wordOffsets, + const char* wordContents, const int16_t* wordXpos, const EpdFontFamily::Style* wordStyles, + const uint8_t* wordBionicBoundary, const uint16_t* wordBionicSuffixX, + const uint16_t* wordGuideDotXOffset, const uint8_t* wordBackgroundBlack, const BlockStyle& blockStyle) + : dataBlock_(std::move(block)), + dataBlockSize_(blockSize), + wordCount_(wordCount), + wordOffsets_(wordOffsets), + wordContents_(wordContents), + wordXpos_(wordXpos), + wordStyles_(wordStyles), + wordBionicBoundary_(wordBionicBoundary), + wordBionicSuffixX_(wordBionicSuffixX), + wordGuideDotXOffset_(wordGuideDotXOffset), + wordBackgroundBlack_(wordBackgroundBlack), + blockStyle_(blockStyle) {} + + // Single owned data block. Holds all per-word arrays + packed + // null-terminated word string contents. Layout is computed at + // construction time; offsets are baked into the pointer members below. + std::unique_ptr dataBlock_; + uint32_t dataBlockSize_ = 0; + + // View pointers into dataBlock_. nullptr means the field is absent + // (e.g. wordBionicBoundary_ when no word in this block has a bionic + // split). All non-null pointers reference memory inside dataBlock_. + uint16_t wordCount_ = 0; + const uint32_t* wordOffsets_ = nullptr; // [wordCount_ + 1]; sentinel at [wordCount_] + const char* wordContents_ = nullptr; // packed null-terminated strings + const int16_t* wordXpos_ = nullptr; + const EpdFontFamily::Style* wordStyles_ = nullptr; + const uint8_t* wordBionicBoundary_ = nullptr; + const uint16_t* wordBionicSuffixX_ = nullptr; + const uint16_t* wordGuideDotXOffset_ = nullptr; + const uint8_t* wordBackgroundBlack_ = nullptr; + + BlockStyle blockStyle_; + + // Compute the byte layout of the compact block. Returns total bytes + // required; out-parameters give the offset of each array inside the block. + static uint32_t computeLayout(uint16_t wordCount, uint32_t wordContentBytes, bool hasBionic, bool hasGuideDots, + uint32_t& outOffWordOffsets, uint32_t& outOffWordXpos, uint32_t& outOffWordStyles, + uint32_t& outOffWordBackgroundBlack, uint32_t& outOffWordBionicBoundary, + uint32_t& outOffWordBionicSuffixX, uint32_t& outOffWordGuideDotXOffset, + uint32_t& outOffWordContents); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 0e88f2b0..21906c50 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -874,7 +874,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* heapBeforeImage.maxAllocHeap, src.c_str()); // Resolve the image path relative to the HTML file. - std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src); + // CrumBLE 4.4: percent-decode the src -- chapter HTML can reference + // an image whose ZIP entry has literal spaces (e.g. "Images/cover + // photo.jpg") via src="../Images/cover%20photo.jpg". Without this, + // the ZIP lookup misses and the image silently doesn't render. + std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + FsHelpers::urlDecode(src)); // CrumBLE: does the optimizer bundle a pre-rendered .pxc next to this // image? If so the image renders from that pixel cache and is NEVER @@ -2163,8 +2167,10 @@ void ChapterHtmlSlimParser::makePages() { currentPageNextY += blockStyle.paddingBottom; } - // Extra paragraph spacing if enabled (default behavior) + // CrumBLE 4.4: paragraph spacing is now three-way (0/1/2). The half- + // lineHeight unit matches the legacy "Extra Spacing = ON" behavior, so + // value 1 reproduces the prior default exactly and value 2 doubles it. if (extraParagraphSpacing) { - currentPageNextY += lineHeight / 2; + currentPageNextY += static_cast(extraParagraphSpacing) * lineHeight / 2; } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index adf37921..75e1fe31 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -48,7 +48,7 @@ class ChapterHtmlSlimParser { int16_t currentPageNextY = 0; int fontId; float lineCompression; - bool extraParagraphSpacing; + uint8_t extraParagraphSpacing; bool forceParagraphIndents; uint8_t paragraphAlignment; uint16_t viewportWidth; @@ -149,7 +149,7 @@ class ChapterHtmlSlimParser { public: explicit ChapterHtmlSlimParser(std::shared_ptr epub, const std::string& filepath, GfxRenderer& renderer, - const int fontId, const float lineCompression, const bool extraParagraphSpacing, + const int fontId, const float lineCompression, const uint8_t extraParagraphSpacing, const bool forceParagraphIndents, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool bionicReadingEnabled, diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 5a03e699..593a5e81 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -197,7 +197,12 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (strcmp(atts[i], "id") == 0) { itemId = atts[i + 1]; } else if (strcmp(atts[i], "href") == 0) { - href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); + // CrumBLE 4.4: percent-decode before ZIP lookup. OPF hrefs are IRIs + // per EPUB spec, so a chapter file literally named "title page.xhtml" + // appears here as "title%20page.xhtml". Without decoding, BMC's size + // probe and SCT's content stream both miss the ZIP entry, and entry + // into the book hangs at "Failed to stream item contents". + href = FsHelpers::normalisePath(self->baseContentPath + FsHelpers::urlDecode(atts[i + 1])); } else if (strcmp(atts[i], "media-type") == 0) { mediaType = atts[i + 1]; } else if (strcmp(atts[i], "properties") == 0) { @@ -319,7 +324,8 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (strcmp(atts[i], "type") == 0) { type = atts[i + 1]; } else if (strcmp(atts[i], "href") == 0) { - guideHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); + // CrumBLE 4.4: see manifest-item href above -- same percent-decode. + guideHref = FsHelpers::normalisePath(self->baseContentPath + FsHelpers::urlDecode(atts[i + 1])); } } if (!guideHref.empty()) { diff --git a/lib/Epub/Epub/parsers/TocNavParser.cpp b/lib/Epub/Epub/parsers/TocNavParser.cpp index 01ca8bef..ba4ae601 100644 --- a/lib/Epub/Epub/parsers/TocNavParser.cpp +++ b/lib/Epub/Epub/parsers/TocNavParser.cpp @@ -126,7 +126,10 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) { if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) { // Create TOC entry when closing anchor tag (we have all data now) if (!self->currentLabel.empty() && !self->currentHref.empty()) { - std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref); + // CrumBLE 4.4: percent-decode so the resulting href matches the + // (now-decoded) manifest hrefs that the cache uses to resolve TOC + // targets to spine indices. See ContentOpfParser.cpp note. + std::string href = FsHelpers::normalisePath(self->baseContentPath + FsHelpers::urlDecode(self->currentHref)); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index 5c0400aa..5647357b 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -145,7 +145,8 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { // This is the safest place to push the data, assuming always comes before . // NCX spec says navLabel comes before content. if (!self->currentLabel.empty() && !self->currentSrc.empty()) { - std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc); + // CrumBLE 4.4: percent-decode -- see ContentOpfParser.cpp note. + std::string href = FsHelpers::normalisePath(self->baseContentPath + FsHelpers::urlDecode(self->currentSrc)); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 891190fd..621afd4a 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -43,6 +43,34 @@ std::string normalisePath(const std::string& path) { return result; } +std::string urlDecode(const std::string& s) { + std::string out; + out.reserve(s.size()); + const size_t n = s.size(); + for (size_t i = 0; i < n; ++i) { + const char c = s[i]; + if (c == '%' && i + 2 < n) { + const char h1 = s[i + 1]; + const char h2 = s[i + 2]; + const auto fromHex = [](char ch) -> int { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return 10 + (ch - 'a'); + if (ch >= 'A' && ch <= 'F') return 10 + (ch - 'A'); + return -1; + }; + const int v1 = fromHex(h1); + const int v2 = fromHex(h2); + if (v1 >= 0 && v2 >= 0) { + out += static_cast((v1 << 4) | v2); + i += 2; + continue; + } + } + out += c; + } + return out; +} + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { // Directories first diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 56c2c987..542deb72 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -9,6 +9,15 @@ namespace FsHelpers { std::string normalisePath(const std::string& path); +// Percent-decode an EPUB href / OPF URI. EPUB hrefs are IRIs per spec, so the +// reading system must decode %XX sequences before treating them as ZIP entry +// names. CrumBLE 4.4: added after a book with literal spaces and ampersands in +// chapter filenames (encoded as %20 and %26 in OPF) hung at chapter entry -- +// the encoded names never matched the ZIP central directory's literal entries. +// Leaves non-percent characters untouched and is tolerant of malformed +// sequences (passes them through verbatim). +std::string urlDecode(const std::string& s); + void sortFileList(std::vector& strs); /** diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b3b9527a..9c75657b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -489,17 +489,60 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3); - if (renderMode == GfxRenderer::BW && bmpVal < 3) { - // Black (also paints over the grays in BW mode) - renderer.drawPixel(screenX, screenY, pixelState); - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - // Light gray (also mark the MSB if it's going to be a dark gray too) - // Dedicated X3 gray LUTs now provide proper 4-level gray on both devices - // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update - renderer.drawPixel(screenX, screenY, false); - } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray - renderer.drawPixel(screenX, screenY, false); + // CrumBLE 4.4 (ported from CPR-vCodex): Text Darkness affects the + // 2-bit grayscale glyph blit only. The font emits bmpVal=0/1/2/3 + // (black / dark / light / white after the inversion above). Each + // darkness mode chooses which AA buckets get inked on the MSB and + // LSB planes -- denser modes promote more buckets, lighter modes + // pull them back. + if (renderMode == GfxRenderer::BW) { + if (bmpVal < 3) { + renderer.drawPixel(screenX, screenY, pixelState); + // CrumBLE 4.4: Extra Dark (mode 3) also thickens the BW glyph + // by 1 pixel along the glyph's horizontal axis. Diverges from + // CPR-vCodex 1:1 -- their Extra Dark only adjusts the grayscale + // hit pattern, which can be too subtle to perceive on X3. The + // outline pixel makes the bolder weight visible regardless of + // Text AA state. Skipped for the lightest AA bucket (bmpVal=2) + // so we don't smear the outermost glyph edges into adjacent + // characters. Glyph-space "next column" maps to screen coords + // differently per rotation; compute accordingly. + if (renderer.getTextDarkness() >= 3 && bmpVal <= 1) { + int outlineX, outlineY; + if constexpr (rotation == TextRotation::Rotated90CW) { + outlineX = screenX; + outlineY = screenY - 1; // glyphX+1 -> screenY-1 in rotated layout + } else { + outlineX = screenX + 1; + outlineY = screenY; + } + renderer.drawPixel(outlineX, outlineY, pixelState); + } + } + } else { + bool hitMsb = false; + bool hitLsb = false; + switch (renderer.getTextDarkness()) { + case 1: // Legacy BW: lighter, pre-CrumBLE 4.4 overlay. + hitMsb = (bmpVal == 2); + hitLsb = (bmpVal == 1); + break; + case 2: // Dark: promote both AA buckets to denser ink. + case 3: // Extra Dark: same hit pattern as Dark for now. + hitMsb = (bmpVal == 1 || bmpVal == 2); + hitLsb = (bmpVal == 1 || bmpVal == 2); + break; + case 0: // Normal: CrossInk-style solid text with smooth AA. + default: + hitMsb = (bmpVal == 1 || bmpVal == 2); + hitLsb = (bmpVal == 1); + break; + } + if (renderMode == GfxRenderer::GRAYSCALE_MSB && hitMsb) { + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && hitLsb) { + renderer.drawPixel(screenX, screenY, false); + } } } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 5744d684..e74bcad2 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -100,6 +100,10 @@ class GfxRenderer { RenderMode renderMode; Orientation orientation; bool fadingFix; + // CrumBLE 4.4 (ported from CPR-vCodex): Text Darkness. Read by the 2-bit + // glyph blit in renderCharImpl to choose which AA buckets get inked. + // 0=Normal, 1=Legacy BW, 2=Dark, 3=Extra Dark. + uint8_t textDarkness = 0; mutable bool renderStarved = false; // Set when an image was decoded this render but not cached to .pxc (partial / // off-screen). Such a page re-decodes on every repaint, so it is not BLE-safe. @@ -287,6 +291,8 @@ class GfxRenderer { // Fading fix control void setFadingFix(const bool enabled) { fadingFix = enabled; } + void setTextDarkness(const uint8_t darkness) { textDarkness = darkness; } + uint8_t getTextDarkness() const { return textDarkness; } // Render-starvation signal. Set when a glyph couldn't be decompressed for OOM // (getGlyphBitmap) or an image failed to decode (ImageBlock::render) — i.e. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 275334ff..133a64a5 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -74,7 +74,7 @@ STR_QUICK_RESUME_ALWAYS_LABEL: "Always" STR_AFTER_TIMEOUT: "After Timeout" STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode" STR_HIDE_BATTERY: "Hide Battery %" -STR_EXTRA_SPACING: "Extra Paragraph Spacing" +STR_EXTRA_SPACING: "Paragraph Spacing" STR_FORCE_PARAGRAPH_INDENTS: "Force Paragraph Indents" STR_TEXT_AA: "Text Anti-Aliasing" STR_IMAGES: "Images" @@ -99,6 +99,15 @@ STR_FONT_FAMILY: "Font Family" STR_FONT_SIZE: "Font Size" STR_SD_FONT_SIZE_RANGE: "Download Font Size Range" STR_LINE_SPACING: "Line Spacing" +STR_READER_DARK_MODE: "Dark Mode" +STR_TEXT_DARKNESS: "Text Darkness" +STR_RETRY_FAILED_COVERS: "Retry Failed Covers" +STR_COVERS_RETRY_DONE: "Reset %d cover(s)" +STR_COVERS_RETRY_NONE: "No covers to retry" +STR_TEXT_DARKNESS_NORMAL: "Normal" +STR_TEXT_DARKNESS_LEGACY_BW: "Legacy BW" +STR_TEXT_DARKNESS_DARK: "Dark" +STR_TEXT_DARKNESS_EXTRA_DARK: "Extra Dark" STR_SCREEN_MARGIN: "Screen Margin" STR_PARA_ALIGNMENT: "Paragraph Alignment" STR_HYPHENATION: "Hyphenation" @@ -337,6 +346,8 @@ STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" STR_DELETE_CACHE: "Delete Book Cache" STR_BOOK_CACHE_DELETED: "Book Cache Deleted" +STR_DELETE_BOOK_STATS: "Delete Book's Reading Stats" +STR_BOOK_STATS_DELETED: "Reading Stats Deleted" STR_DELETE: "Delete" STR_PIN_AS_FAVORITE: "Pin as Favorite" STR_UNPIN_AS_FAVORITE: "Unpin as Favorite" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 101bfbd4..de1fe87d 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -666,6 +666,8 @@ uint8_t CrossPointSettings::getReaderFontPointSize(const FONT_SIZE size) { switch (size) { case TEENSY: return 8; + case ITTY_BITTY: + return 9; case TINY: return 10; case SMALL: diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 133e5077..ede5b424 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -167,6 +167,28 @@ class CrossPointSettings { SD_FONT_SIZE_RANGE_COUNT }; enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; + // CrumBLE 4.4 (ported from CPR-vCodex): Text Darkness setting. Affects the + // 2-bit grayscale glyph blit -- maps the font's per-pixel AA value (0..3) + // to which framebuffer plane (MSB, LSB) gets ink. Doesn't touch the 1-bit + // BW path. Stored as uint8_t (textDarkness) so layouts stay compact. + // NORMAL : CrossInk-style solid text with smooth AA (current behaviour) + // LEGACY_BW : Lighter overlay, the pre-CrumBLE 4.4 look + // DARK : Both AA buckets get inked, denser glyphs + // EXTRA_DARK: Same as DARK in the current renderer (reserved for future tuning) + enum TEXT_DARKNESS { + TEXT_DARKNESS_NORMAL = 0, + TEXT_DARKNESS_LEGACY_BW = 1, + TEXT_DARKNESS_DARK = 2, + TEXT_DARKNESS_EXTRA_DARK = 3, + TEXT_DARKNESS_COUNT + }; + // Spacing *between* paragraphs. Three-way enum (the byte field + // `extraParagraphSpacing` carries 0/1/2). TIGHT keeps the classic-novel + // text-indent style with no vertical gap; NORMAL is the default block-style + // paragraph with a lineHeight/2 gap; WIDE doubles that to a full lineHeight. + // The byte format is unchanged from the legacy bool, so old config files + // and old section caches with 0/1 round-trip identically. + enum EXTRA_PARAGRAPH_SPACING { EPS_TIGHT = 0, EPS_NORMAL = 1, EPS_WIDE = 2, EXTRA_PARAGRAPH_SPACING_COUNT }; enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, @@ -282,6 +304,7 @@ class CrossPointSettings { LONG_MENU_FILE_TRANSFER = 12, LONG_MENU_BOOK_SETTINGS = 13, LONG_MENU_TOGGLE_TILT_PAGE_TURN = 14, + LONG_MENU_TOGGLE_DARK_MODE = 15, LONG_PRESS_MENU_ACTION_COUNT }; @@ -397,6 +420,15 @@ class CrossPointSettings { #endif uint8_t lineSpacing = NORMAL; uint8_t paragraphAlignment = JUSTIFIED; + // CrumBLE 4.4: ported from upstream CrossInk v1.3.2. Selective reader-page + // inversion -- black page background with white text/UI, but EPUB content + // images render right-side up (no inverted-photo weirdness). Reader-only; + // menus, file browser, and the sleep screen remain in light mode. + uint8_t readerDarkMode = 0; + // CrumBLE 4.4 (ported from CPR-vCodex): Text Darkness, 0=Normal, 1=Legacy + // BW, 2=Dark, 3=Extra Dark. Sync to GfxRenderer.setTextDarkness() at boot + // and whenever this field is edited so the next glyph blit uses it. + uint8_t textDarkness = TEXT_DARKNESS_NORMAL; // Auto-sleep timeout setting (default 10 minutes). Legacy sleepTimeout enum values are migration-only. uint8_t sleepTimeoutMinutes = 10; // E-ink refresh frequency (default 15 pages) @@ -454,6 +486,12 @@ class CrossPointSettings { uint8_t bionicReadingEnabled = 0; // Guide Dots - places a middle dot between words to guide the eye uint8_t guideReadingEnabled = 0; + // Glyph atlas (v40 section format): when enabled, the reader installs and + // renders from the prebake'd glyph atlas. Default ON to keep the existing + // optimized render path. Turn OFF to A/B test the upload-reliability + // regression hypothesis -- when off, the renderer falls back to the v39 + // embedded glyph subset (or full SD-font glyph fetch if neither exists). + uint8_t glyphAtlasEnabled = 1; // SD card font family name, including optional range suffix (empty = use built-in fontFamily) char sdFontFamilyName[64] = ""; // Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show) diff --git a/src/SettingsList.h b/src/SettingsList.h index c0b11bf5..0ab2dded 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -16,58 +16,77 @@ #include "KOReaderCredentialStore.h" #include "activities/settings/SettingsActivity.h" -inline StrId fontSizeLabelForPointSize(const uint8_t pointSize) { - switch (pointSize) { - case 8: - return StrId::STR_TEENSY; - case 9: - return StrId::STR_ITTY_BITTY; - case 10: - return StrId::STR_TINY; - case 12: - return StrId::STR_SMALL; - case 14: - return StrId::STR_MEDIUM; - case 16: - return StrId::STR_LARGE; - case 18: - return StrId::STR_X_LARGE; - case 20: - return StrId::STR_HUGE; - default: - return StrId::STR_NONE_OPT; - } +// CrumBLE 4.4 (ported from upstream CrossInk v1.3.2): font sizes now display +// as " pt" instead of friendly names like "Tiny" / "Small" / "Medium". +// This lets SD-card font sizes interleave intuitively with built-in sizes +// (the user sees "10 pt / 12 pt / 14 pt" everywhere instead of a mix of +// "Tiny / Small / Medium" and bare "11 pt / 13 pt"). +inline std::string fontSizePointLabel(const uint8_t pointSize) { + return std::to_string(static_cast(pointSize)) + " pt"; } inline SettingInfo buildBuiltinFontSizeSetting() { - return SettingInfo::Enum(StrId::STR_FONT_SIZE, &CrossPointSettings::fontSize, - { + SettingInfo s; + s.nameId = StrId::STR_FONT_SIZE; + s.type = SettingType::ENUM; + s.valuePtr = &CrossPointSettings::fontSize; + s.key = "fontSize"; + s.category = StrId::STR_CAT_READER; + + // Order mirrors the prior StrId list: TINY, SMALL, MEDIUM, LARGE, X_LARGE, + // TEENSY, HUGE, ITTY_BITTY -- matches the user's current muscle memory of + // where each size sits in the cycle. + using S = CrossPointSettings; + struct Entry { S::FONT_SIZE raw; bool include; }; + const Entry entries[] = { #ifndef OMIT_TINY_FONT - StrId::STR_TINY, + {S::TINY, true}, +#else + {S::TINY, false}, #endif #ifndef OMIT_SMALL_FONT - StrId::STR_SMALL, + {S::SMALL, true}, +#else + {S::SMALL, false}, #endif #ifndef OMIT_MEDIUM_FONT - StrId::STR_MEDIUM, + {S::MEDIUM, true}, +#else + {S::MEDIUM, false}, #endif #ifndef OMIT_LARGE_FONT - StrId::STR_LARGE, + {S::LARGE, true}, +#else + {S::LARGE, false}, #endif #ifndef OMIT_XLARGE_FONT - StrId::STR_X_LARGE, + {S::EXTRA_LARGE, true}, +#else + {S::EXTRA_LARGE, false}, #endif #ifndef OMIT_TEENSY_FONT - StrId::STR_TEENSY, + {S::TEENSY, true}, +#else + {S::TEENSY, false}, #endif #ifndef OMIT_HUGE_FONT - StrId::STR_HUGE, + {S::HUGE_SIZE, true}, +#else + {S::HUGE_SIZE, false}, #endif #ifndef OMIT_ITTY_BITTY_FONT - StrId::STR_ITTY_BITTY, + {S::ITTY_BITTY, true}, +#else + {S::ITTY_BITTY, false}, #endif - }, - "fontSize", StrId::STR_CAT_READER); + }; + + for (const auto& e : entries) { + if (!e.include) continue; + s.enumStringValues.push_back(fontSizePointLabel(S::getReaderFontPointSize(e.raw))); + s.enumRawValues.push_back(static_cast(e.raw)); + } + return s; } inline SettingInfo buildSdFontSizeSetting(const SdCardFontFamilyInfo& family) { @@ -82,8 +101,8 @@ inline SettingInfo buildSdFontSizeSetting(const SdCardFontFamilyInfo& family) { s.enumStringValues.reserve(sizes.size()); s.enumRawValues.reserve(sizes.size()); for (size_t i = 0; i < sizes.size(); i++) { - const StrId labelId = fontSizeLabelForPointSize(sizes[i]); - s.enumStringValues.push_back(labelId != StrId::STR_NONE_OPT ? I18N.get(labelId) : std::to_string(sizes[i]) + " pt"); + // CrumBLE 4.4: same pt-label format as builtin sizes; no special-casing. + s.enumStringValues.push_back(fontSizePointLabel(sizes[i])); s.enumRawValues.push_back(static_cast(i)); } return s; @@ -451,17 +470,39 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist StrId::STR_CAT_READER)); add(SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing", StrId::STR_CAT_READER)); + add(SettingInfo::Toggle(StrId::STR_READER_DARK_MODE, &CrossPointSettings::readerDarkMode, "readerDarkMode", + StrId::STR_CAT_READER)); + // CrumBLE 4.4 (ported from CPR-vCodex): Text Darkness, 4-way enum. + // Affects only the 2-bit grayscale glyph blit (visible when Text AA is on). + add(SettingInfo::Enum(StrId::STR_TEXT_DARKNESS, &CrossPointSettings::textDarkness, + {StrId::STR_TEXT_DARKNESS_NORMAL, StrId::STR_TEXT_DARKNESS_LEGACY_BW, + StrId::STR_TEXT_DARKNESS_DARK, StrId::STR_TEXT_DARKNESS_EXTRA_DARK}, + "textDarkness", StrId::STR_CAT_READER)); add(SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER)); - add(SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing, - "extraParagraphSpacing", StrId::STR_CAT_READER)); + // CrumBLE 4.4: promoted from a two-state toggle ("Extra Spacing") to a + // three-way enum ("Paragraph Spacing"). 0/TIGHT = classic text-indent + // paragraphs with no vertical gap; 1/NORMAL = block-style paragraphs with + // a lineHeight/2 gap (the prior toggle-on default); 2/WIDE = block-style + // with a full lineHeight gap. The on-disk byte format is unchanged so + // existing configs and section caches with 0/1 round-trip identically. + add(SettingInfo::Enum(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing, + {StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE}, + "extraParagraphSpacing", StrId::STR_CAT_READER)); add(SettingInfo::Toggle(StrId::STR_FORCE_PARAGRAPH_INDENTS, &CrossPointSettings::forceParagraphIndents, "forceParagraphIndents", StrId::STR_CAT_READER)); add(SettingInfo::Toggle(StrId::STR_BIONIC_READING, &CrossPointSettings::bionicReadingEnabled, "bionicReadingEnabled", StrId::STR_CAT_READER)); add(SettingInfo::Toggle(StrId::STR_GUIDE_READING, &CrossPointSettings::guideReadingEnabled, "guideReadingEnabled", StrId::STR_CAT_READER)); + // CrumBLE 4.4: persistence-only registration (no on-device Settings UI). + // Diagnostic toggle to A/B test whether the v40 glyph atlas install path + // is the source of the FT upload heap regression. Default ON keeps the + // optimized render path; flip OFF via /api/save-reader-settings to fall + // back to the v39 embedded subset (or full SD-font fetch) path. + add(SettingInfo::Toggle(StrId::STR_NONE_OPT, &CrossPointSettings::glyphAtlasEnabled, + "glyphAtlasEnabled")); // --- Controls --- add(SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, diff --git a/src/activities/reader/BookSettingsDrawerActivity.cpp b/src/activities/reader/BookSettingsDrawerActivity.cpp index f3d5a4a8..351574aa 100644 --- a/src/activities/reader/BookSettingsDrawerActivity.cpp +++ b/src/activities/reader/BookSettingsDrawerActivity.cpp @@ -13,6 +13,7 @@ #include "CrossPointSettings.h" #include "EpubReaderActivity.h" // prewarmReaderTextBuffer #include "MappedInputManager.h" +#include "ReaderUtils.h" #include "SdCardFontSystem.h" // sdFontSystem for SD-aware FONT_FAMILY / FONT_SIZE rows #include "SettingsList.h" #include "components/UITheme.h" @@ -679,23 +680,34 @@ void BookSettingsDrawerActivity::renderDrawer() { } // else: intentionally no clearScreen() — see comment above. - // Panel body — filled white, with the top two corners rounded so the panel + // CrumBLE 4.4: dark-mode-aware drawer. When the reader is in dark mode, + // the drawer panel inverts (black fill, white borders, white text) so it + // visually matches the dark page underneath instead of flashing as a + // bright white panel. Selected-row highlight also flips: white fill + + // black text in dark mode, mirroring the page-level reverse-video pattern + // used for selection highlights and the lookup popup hints. + const bool darkMode = ReaderUtils::readerDarkModeEnabled(); + const Color panelFill = darkMode ? Color::Black : Color::White; + const bool panelStrokeInk = !darkMode; // true=black stroke in light; false=white stroke in dark + const bool panelTextBlack = !darkMode; + + // Panel body — filled, with the top two corners rounded so the panel // looks like it has rounded shoulders. const int panelTopY = drawerY + kTabOverlap; const int panelBodyH = drawerH - kTabOverlap; renderer.fillRoundedRect(drawerX, panelTopY, drawerW, panelBodyH, kPanelCornerRadius, - /*topL=*/true, /*topR=*/true, /*botL=*/false, /*botR=*/false, Color::White); + /*topL=*/true, /*topR=*/true, /*botL=*/false, /*botR=*/false, panelFill); // Panel top edge — a horizontal line between the rounded corners. The middle // section will be overpainted by the tab below so it appears to pass "behind" // the tab outline. renderer.drawLine(drawerX + kPanelCornerRadius, panelTopY, - drawerX + drawerW - kPanelCornerRadius - 1, panelTopY, 2, true); + drawerX + drawerW - kPanelCornerRadius - 1, panelTopY, 2, panelStrokeInk); // Quarter-circle outlines for the panel's top corners (matches the fill). renderer.drawArc(kPanelCornerRadius, drawerX + kPanelCornerRadius, panelTopY + kPanelCornerRadius, - -1, -1, 2, true); + -1, -1, 2, panelStrokeInk); renderer.drawArc(kPanelCornerRadius, drawerX + drawerW - kPanelCornerRadius - 1, - panelTopY + kPanelCornerRadius, 1, -1, 2, true); + panelTopY + kPanelCornerRadius, 1, -1, 2, panelStrokeInk); // Tab — centred above the panel's top edge. Width auto-fits the header text // with horizontal padding; bottom of the tab extends kTabOverlap into the @@ -710,21 +722,21 @@ void BookSettingsDrawerActivity::renderDrawer() { // 1) Erase the panel's top line where it would pass through the tab. renderer.fillRoundedRect(tabX, tabY, tabW, kTabHeight, kTabCornerRadius, - /*topL=*/true, /*topR=*/true, /*botL=*/false, /*botR=*/false, Color::White); + /*topL=*/true, /*topR=*/true, /*botL=*/false, /*botR=*/false, panelFill); // 2) Tab outline — top + rounded top corners + left and right sides only. // Bottom side intentionally omitted so the tab visually merges with the panel. - renderer.drawLine(tabX + kTabCornerRadius, tabY, tabX + tabW - kTabCornerRadius - 1, tabY, 2, true); - renderer.drawArc(kTabCornerRadius, tabX + kTabCornerRadius, tabY + kTabCornerRadius, -1, -1, 2, true); - renderer.drawArc(kTabCornerRadius, tabX + tabW - kTabCornerRadius - 1, tabY + kTabCornerRadius, 1, -1, 2, true); - renderer.drawLine(tabX, tabY + kTabCornerRadius, tabX, tabBottomY - 1, 2, true); - renderer.drawLine(tabX + tabW - 1, tabY + kTabCornerRadius, tabX + tabW - 1, tabBottomY - 1, 2, true); + renderer.drawLine(tabX + kTabCornerRadius, tabY, tabX + tabW - kTabCornerRadius - 1, tabY, 2, panelStrokeInk); + renderer.drawArc(kTabCornerRadius, tabX + kTabCornerRadius, tabY + kTabCornerRadius, -1, -1, 2, panelStrokeInk); + renderer.drawArc(kTabCornerRadius, tabX + tabW - kTabCornerRadius - 1, tabY + kTabCornerRadius, 1, -1, 2, panelStrokeInk); + renderer.drawLine(tabX, tabY + kTabCornerRadius, tabX, tabBottomY - 1, 2, panelStrokeInk); + renderer.drawLine(tabX + tabW - 1, tabY + kTabCornerRadius, tabX + tabW - 1, tabBottomY - 1, 2, panelStrokeInk); // Tab text — vertically centred in the upper portion of the tab so it sits // above the panel's top line. const int tabTextX = tabX + (tabW - headerTextW) / 2; const int tabTextY = tabY + 6; - renderer.drawText(UI_12_FONT_ID, tabTextX, tabTextY, headerText, true, EpdFontFamily::BOLD); + renderer.drawText(UI_12_FONT_ID, tabTextX, tabTextY, headerText, panelTextBlack, EpdFontFamily::BOLD); // Item list — starts a small pad below the panel's top line. const int listStartY = panelTopY + kListTopPad; @@ -742,7 +754,11 @@ void BookSettingsDrawerActivity::renderDrawer() { const int rowY = listStartY + i * itemHeight; const bool selected = (idx == selectedIndex); if (selected) { - renderer.fillRect(drawerX + 1, rowY, drawerW - 2, itemHeight, true); + // Selection highlight uses inverse video relative to the panel: in + // light mode the panel is white so highlight is black; in dark mode + // the panel is black so highlight is white. panelStrokeInk doubles + // as "draw with ink" which matches the panel's contrast direction. + renderer.fillRect(drawerX + 1, rowY, drawerW - 2, itemHeight, panelStrokeInk); } const char* name = !item.customName.empty() ? item.customName.c_str() : I18N.get(item.nameId); const auto& src = currentSettings(); @@ -750,7 +766,10 @@ void BookSettingsDrawerActivity::renderDrawer() { (item.settingIndex >= 0 && item.settingIndex < static_cast(src.size())) ? valueTextForSetting(src[item.settingIndex]) : std::string{}; - const bool textBlack = !selected; + // Row text color: when NOT selected, draw with the panel's primary text + // color (black in light, white in dark). When selected, the highlight rect + // has inverted; draw text in the opposite color so it stays legible. + const bool textBlack = selected ? darkMode : panelTextBlack; renderer.drawText(UI_12_FONT_ID, drawerX + leftPad, rowY + rowTextY, name, textBlack); if (!value.empty()) { const int valueWidth = renderer.getTextWidth(UI_12_FONT_ID, value.c_str()); @@ -769,7 +788,7 @@ void BookSettingsDrawerActivity::renderDrawer() { const int barH = std::max(8, (trackH * itemsVisible) / static_cast(items.size())); const int barY = listStartY + (trackH - barH) * scrollOffset / std::max(1, static_cast(items.size()) - itemsVisible); - renderer.fillRect(drawerX + drawerW - 4, barY, 2, barH, true); + renderer.fillRect(drawerX + drawerW - 4, barY, 2, barH, panelStrokeInk); } // Button hints. @@ -782,7 +801,7 @@ void BookSettingsDrawerActivity::renderDrawer() { // the middle, dpad Up/Down on the RIGHT. Reading the hint left-to-right // now mirrors the user's eye as it scans across the bottom of the device. std::string hintLine = std::string(labels.btn1) + " · " + labels.btn2 + " · " + labels.btn3 + "/" + labels.btn4; - renderer.drawCenteredText(SMALL_FONT_ID, hintY, hintLine.c_str(), true); + renderer.drawCenteredText(SMALL_FONT_ID, hintY, hintLine.c_str(), panelTextBlack); } void BookSettingsDrawerActivity::presentFastRefresh() { diff --git a/src/activities/reader/DictionaryWordSelectActivity.cpp b/src/activities/reader/DictionaryWordSelectActivity.cpp index f20a980f..03460553 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.cpp +++ b/src/activities/reader/DictionaryWordSelectActivity.cpp @@ -13,10 +13,17 @@ #include #include +#include + +#include "../../SilentRestart.h" #include "BookmarkStore.h" // BOOKMARK_PREVIEW_MAX #include "DictionaryDefinitionActivity.h" #include "MappedInputManager.h" +#include "ReaderUtils.h" #include "components/UITheme.h" +#include "fontIds.h" +#include "util/Dictionary.h" +#include "util/LookupHistory.h" DictionaryWordSelectActivity::DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr page, int fontId, int marginLeft, @@ -101,7 +108,7 @@ void DictionaryWordSelectActivity::extractWords() { } for (size_t i = 0; i < lineWords.size(); ++i) { - const std::string& raw = lineWords[i]; + const std::string raw(lineWords[i].data(), lineWords[i].size()); int16_t baseX = marginLeft + line.xPos + wordXpos[i]; std::string prefix = ""; @@ -196,6 +203,72 @@ int DictionaryWordSelectActivity::findClosestWordIndexInRow(int rowIndex, int ta } void DictionaryWordSelectActivity::loop() { + // CrumBLE 4.4 post-bisect: post-silent-restart restore is two-phase. + // Phase 1 (first loop tick after onEnter+first render): navigate the + // cursor to the previously-looked-up word and request a redraw. We + // can't open the overlay yet because storeBwBuffer would capture the + // selection screen with the WRONG cursor position (the previous render + // used the default cursor). + // Phase 2 (next loop tick, after the render task has redrawn with the + // correct cursor): open the definition overlay. The capture now lands + // on the user's pre-restart context: same page, cursor on the word + // they were looking up. + if (!pendingDefinitionWord_.empty() && !defOverlay_) { + if (!pendingDefinitionCursorMoved_) { + // Phase 1: locate the word + move cursor + redraw. + for (size_t i = 0; i < words.size(); ++i) { + if (words[i].lookupText == pendingDefinitionWord_) { + for (size_t r = 0; r < rows.size(); ++r) { + for (size_t w = 0; w < rows[r].wordIndices.size(); ++w) { + if (rows[r].wordIndices[w] == static_cast(i)) { + currentRow = static_cast(r); + currentWordInRow = static_cast(w); + goto cursorMoved; + } + } + } + } + } + cursorMoved: + pendingDefinitionCursorMoved_ = true; + requestUpdate(); + return; + } + // Phase 2: cursor is rendered into the framebuffer; safe to capture. + std::string word = std::move(pendingDefinitionWord_); + pendingDefinitionWord_.clear(); + pendingDefinitionCursorMoved_ = false; + if (pendingOpenOverlay_) { + openDefinitionOverlay(word); + } else { + // Cursor-only restore (OpenLookupAtWord path). Reset the flag for any + // subsequent setPendingDefinitionWord call that doesn't pass it. + pendingOpenOverlay_ = true; + } + return; + } + + // CrumBLE 4.4 post-bisect: when the definition overlay is open, all + // button input drives the overlay (scroll / dismiss). Word-selection + // navigation underneath is frozen until the overlay closes. + if (defOverlay_) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + closeDefinitionOverlay(); + return; + } + if (defMaxScroll_ > 0) { + if (mappedInput.wasReleased(MappedInputManager::Button::Up) && defScrollOffset_ > 0) { + defScrollOffset_ = std::max(0, defScrollOffset_ - std::max(1, defLinesPerPage_ - 1)); + requestUpdate(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Down) && defScrollOffset_ < defMaxScroll_) { + defScrollOffset_ = std::min(defMaxScroll_, defScrollOffset_ + std::max(1, defLinesPerPage_ - 1)); + requestUpdate(); + } + } + return; + } + if (rows.empty()) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { @@ -256,10 +329,7 @@ void DictionaryWordSelectActivity::loop() { int selectedWordIdx = rows[currentRow].wordIndices[currentWordInRow]; if (mode_ == Mode::Lookup) { - std::string wordToLookup = words[selectedWordIdx].lookupText; - startActivityForResult( - std::make_unique(renderer, mappedInput, wordToLookup, cachePath, fontId), - [this](const ActivityResult& result) { requestUpdate(); }); + openDefinitionOverlay(words[selectedWordIdx].lookupText); } else if (mode_ == Mode::HighlightSingleWord) { // One-tap mode for cross-page END pick. Capture some lead-in // context BEFORE the picked word so the saved preview reads @@ -334,7 +404,16 @@ void DictionaryWordSelectActivity::loop() { } void DictionaryWordSelectActivity::render(RenderLock&&) { - renderer.clearScreen(); + // CrumBLE 4.4 post-bisect: when the definition overlay is open, restore + // the captured word-selection screen first, then paint the popup on top. + // We re-restore on every render so scroll/dismiss redraws don't accumulate + // stale popup pixels. Capture happened in openDefinitionOverlay before + // this render fires. + if (defOverlay_) { + renderDefinitionOverlay(); + return; + } + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); // CrumBLE 4.2: prewarm the font cache before page->render. The parent // EpubReaderActivity::renderContents wraps its own page render in a @@ -363,7 +442,7 @@ void DictionaryWordSelectActivity::render(RenderLock&&) { page->renderText(renderer, fontId, marginLeft, marginTop); // scan pass: records text, doesn't draw prewarmScope->endScanAndPrewarm(); } - page->render(renderer, fontId, marginLeft, marginTop); + page->render(renderer, fontId, marginLeft, marginTop, ReaderUtils::readerForegroundBlack()); // CrumBLE: belt and suspenders -- extractWords now skips empty rows, // but if currentRow lands on one anyway (defensive) or words is empty @@ -374,16 +453,23 @@ void DictionaryWordSelectActivity::render(RenderLock&&) { int selectedWordIdx = rows[currentRow].wordIndices[currentWordInRow]; const int lineHeight = renderer.getLineHeight(fontId); + // Dark-mode-aware highlight color. Selection visuals always show reverse + // video relative to the page: black box on a white page, white box on a + // dark page. Without this, in dark mode the black focus reticle and + // range fill draw black-on-black and the selection becomes invisible. + const bool highlightInk = ReaderUtils::readerForegroundBlack(); + const bool highlightTextBlack = !highlightInk; + auto drawSingleWordBox = [&](int index) { const WordInfo& word = words[index]; int boxX = word.screenX; int boxY = word.screenY; int boxWidth = word.width; - renderer.fillRect(boxX, boxY + lineHeight + 2, boxWidth, 3, true); - renderer.fillRect(boxX, boxY - 3, boxWidth, 1, true); - renderer.fillRect(boxX - 3, boxY - 3, 2, lineHeight + 8, true); - renderer.fillRect(boxX + boxWidth + 1, boxY - 3, 2, lineHeight + 8, true); + renderer.fillRect(boxX, boxY + lineHeight + 2, boxWidth, 3, highlightInk); + renderer.fillRect(boxX, boxY - 3, boxWidth, 1, highlightInk); + renderer.fillRect(boxX - 3, boxY - 3, 2, lineHeight + 8, highlightInk); + renderer.fillRect(boxX + boxWidth + 1, boxY - 3, 2, lineHeight + 8, highlightInk); }; // HighlightRange mode + anchor placed: render a filled black box @@ -410,18 +496,20 @@ void DictionaryWordSelectActivity::render(RenderLock&&) { const int gapEnd = words[i + 1].screenX; if (gapEnd > gapStart) { renderer.fillRect(gapStart - padX, words[i].screenY - padTop, - (gapEnd - gapStart) + padX * 2, lineHeight + padTop + padBot, true); + (gapEnd - gapStart) + padX * 2, lineHeight + padTop + padBot, highlightInk); } } - // Second pass: fill the words themselves and redraw in white. + // Second pass: fill the words themselves and redraw in the inverse ink. for (int i = lo; i <= hi && i < static_cast(words.size()); ++i) { const WordInfo& w = words[i]; if (w.continuationOf != -1) continue; renderer.fillRect(w.screenX - padX, w.screenY - padTop, w.width + padX * 2, - lineHeight + padTop + padBot, true); - // textBlack=false -> white text. Style defaults to REGULAR since - // we don't store per-word EpdFontFamily::Style in WordInfo yet. - renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false); + lineHeight + padTop + padBot, highlightInk); + // Reverse-video text: in light mode highlightInk=true (black fill) + // so text draws white; in dark mode highlightInk=false (white fill) + // so text draws black. Style defaults to REGULAR since we don't + // store per-word EpdFontFamily::Style in WordInfo yet. + renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), highlightTextBlack); } } else { drawSingleWordBox(selectedWordIdx); @@ -452,7 +540,8 @@ void DictionaryWordSelectActivity::render(RenderLock&&) { // physical button shows its own hint instead of one button labelled // "Prev/Next" and the other left blank. const auto labels = mappedInput.mapLabels(btn1Label, btn2Label, tr(STR_PREV), tr(STR_NEXT)); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, + /*allowInvertedText=*/false, ReaderUtils::readerDarkModeEnabled()); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } @@ -490,4 +579,251 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() { words[i + 1].continuationOf = static_cast(i); } } +} + +// CrumBLE 4.4 post-bisect: inline definition overlay implementation. Replaces +// the prior DictionaryDefinitionActivity push pattern -- now the popup draws +// on top of the captured word-selection screen and dismissal restores the +// underlying frame via the renderer's BW backup (~2-5 KB packbits-compressed). +// User's selection cursor is preserved across the lookup. + +void DictionaryWordSelectActivity::openDefinitionOverlay(const std::string& word) { + // CrumBLE 4.4 post-bisect: NO preemptive pre-flight gate. We try the lookup + // first and only silent-restart when wrap actually fails. This keeps + // common-case lookups fast (no unnecessary restarts) AND lets the silent- + // restart-with-word recovery path kick in for the genuinely heap-starved case. + // See wrapDefinition() for the recovery trigger. + clearSilentRebootContinuationFlag(); + defTargetWord_ = word; + defLines_.clear(); + defLines_.shrink_to_fit(); + defScrollOffset_ = 0; + defOverlayNotFound_ = false; + defOverlayLowMemory_ = false; + defOverlayLoading_ = true; + // Capture the current word-selection screen so we can paint the popup + // on top without losing the underlying frame. storeBwBuffer compresses + // with packbits (2-5 KB for a text page), so this is cheap even on a + // fragmented post-BT heap. + defOverlayCaptureValid_ = renderer.storeBwBuffer(); + if (!defOverlayCaptureValid_) { + LOG_INF("DICT", "openDefinitionOverlay: storeBwBuffer failed; overlay will repaint via word-select re-render on dismiss"); + } + defOverlay_ = true; + // Render the "looking up" popup state first so the user sees instant feedback, + // then perform the synchronous lookup, then re-render with the result. + requestUpdate(); + performDefinitionLookup(); + defOverlayLoading_ = false; + requestUpdate(); +} + +void DictionaryWordSelectActivity::performDefinitionLookup() { + std::string definition = Dictionary::lookup(defTargetWord_); + if (definition.empty()) { + auto stems = Dictionary::getStemVariants(defTargetWord_); + for (const auto& stem : stems) { + definition = Dictionary::lookup(stem); + if (!definition.empty()) { + defTargetWord_ = stem; + break; + } + } + } + Dictionary::freeMemory(); + if (definition.empty()) { + defOverlayNotFound_ = true; + return; + } + LookupHistory::addWord(cachePath, defTargetWord_); + wrapDefinition(definition); +} + +void DictionaryWordSelectActivity::wrapDefinition(const std::string& definition) { + // CrumBLE 4.4 post-bisect: NO silent-restart from this path. Heap corruption + // crashes were repeatedly triggered by the silent-restart-into-LOOKUP-with- + // word flow (multi_heap_free assert during post-boot dispatch's LOOKUP + // setup). Instead, show the "Low memory" message and let the user back out + // through the existing flow: dismiss popup -> back to text-selection -> + // back to reader -> re-invoke Lookup. The Lookup entry's pre-flight gate + // (already battle-tested) handles the silent-restart, opening text-selection + // fresh -- one extra step for the user, but reliably crash-free. + if (ESP.getMaxAllocHeap() < 10 * 1024) { + LOG_ERR("DICT", + "wrapDefinition: maxAlloc=%u too low (< 10 KB); showing Low-memory popup " + "(user dismisses + re-invokes Lookup to silent-restart cleanly)", + ESP.getMaxAllocHeap()); + defOverlayLowMemory_ = true; + return; + } + // CrumBLE 4.4 post-bisect: kindle-style bottom-pinned popup. The popup + // occupies ~45% of screen height at the bottom; the top half of the + // book page stays visible (via the restored BW buffer) so the user + // keeps surrounding context while reading the definition. + const int kPopupHorizMargin = 16; + const int kPopupBottomMargin = 60; // leave room for button hints below + const int kPopupPadding = 12; + const int popupMaxWidth = renderer.getScreenWidth() - (kPopupHorizMargin * 2) - (kPopupPadding * 2); + defLines_.reserve(20); + std::stringstream ss(definition); + std::string paragraph; + while (std::getline(ss, paragraph, '\n')) { + if (paragraph.empty()) { + defLines_.push_back(""); + continue; + } + auto pLines = renderer.wrappedText(BITTER_12_FONT_ID, paragraph.c_str(), popupMaxWidth, 1000); + for (auto& line : pLines) defLines_.push_back(std::move(line)); + } + const int lineHeight = renderer.getLineHeight(BITTER_12_FONT_ID); + const int titleH = renderer.getLineHeight(BITTER_12_FONT_ID); + const int popupHeight = (renderer.getScreenHeight() * 45) / 100; // ~45% of screen + const int popupInnerHeight = popupHeight - (kPopupPadding * 2) - titleH - 6; + defLinesPerPage_ = std::max(1, popupInnerHeight / lineHeight); + defMaxScroll_ = std::max(0, static_cast(defLines_.size()) - defLinesPerPage_); +} + +void DictionaryWordSelectActivity::renderDefinitionOverlay() { + // Restore the captured selection screen so this render lands on top of the + // user's pre-overlay context. If capture failed (heap too tight for the + // compressed BW backup), DON'T clearScreen -- that would wipe the + // underlying selection page to white. Instead, paint the popup over the + // current framebuffer, which still holds the selection screen from the + // previous render. The book text stays visible above the popup; the only + // cost is potential pixel artifacts inside the popup region if the user + // scrolls (since each scroll paints over the previous popup state without + // a clean restore). Acceptable -- much better than a white page. + if (defOverlayCaptureValid_) { + renderer.restoreBwBuffer(); + } + + // CrumBLE 4.4 post-bisect: kindle-style bottom-pinned popup so the top + // half of the book page (restored via the BW buffer above) remains + // visible behind the definition. White fill inside the popup keeps the + // definition text legible; thick top border draws the eye to the new + // surface without losing reading context. + const int kPopupHorizMargin = 16; + const int kPopupBottomMargin = 60; // leaves room for button hints below + const int kPopupPadding = 12; + const int popupW = renderer.getScreenWidth() - (kPopupHorizMargin * 2); + const int popupH = (renderer.getScreenHeight() * 45) / 100; + const int popupX = kPopupHorizMargin; + const int popupY = renderer.getScreenHeight() - kPopupBottomMargin - popupH; + + // White fill so the definition text is legible; the page text above the + // popup is preserved by the restoreBwBuffer earlier in this render. + renderer.fillRect(popupX, popupY, popupW, popupH, false); // white fill + renderer.fillRect(popupX, popupY, popupW, 3, true); // thicker top border (eye-draw) + renderer.fillRect(popupX, popupY + popupH - 2, popupW, 2, true); // bottom + renderer.fillRect(popupX, popupY, 2, popupH, true); // left + renderer.fillRect(popupX + popupW - 2, popupY, 2, popupH, true); // right + + const int textX = popupX + kPopupPadding; + int currentY = popupY + kPopupPadding; + + if (defOverlayLoading_) { + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, tr(STR_LOOKING_UP)); + } else if (defOverlayLowMemory_) { + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, defTargetWord_.c_str(), EpdFontFamily::BOLD); + currentY += renderer.getLineHeight(BITTER_12_FONT_ID) * 2; + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, "Low memory -- back out"); + currentY += renderer.getLineHeight(BITTER_12_FONT_ID); + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, "and reopen Lookup"); + } else if (defOverlayNotFound_) { + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, defTargetWord_.c_str(), EpdFontFamily::BOLD); + currentY += renderer.getLineHeight(BITTER_12_FONT_ID) * 2; + std::string msg = std::string(tr(STR_WORD_NOT_FOUND)) + defTargetWord_; + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, msg.c_str()); + } else { + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, defTargetWord_.c_str(), EpdFontFamily::BOLD); + const int titleH = renderer.getLineHeight(BITTER_12_FONT_ID); + const int titleWidth = renderer.getTextWidth(BITTER_12_FONT_ID, defTargetWord_.c_str(), EpdFontFamily::BOLD); + renderer.fillRect(textX, currentY + titleH + 4, titleWidth, 2, true); + currentY += titleH + (kPopupPadding); + const int lineHeight = renderer.getLineHeight(BITTER_12_FONT_ID); + const int linesToDraw = std::min(defLinesPerPage_, static_cast(defLines_.size()) - defScrollOffset_); + for (int i = 0; i < linesToDraw; ++i) { + renderer.drawText(BITTER_12_FONT_ID, textX, currentY, defLines_[defScrollOffset_ + i].c_str()); + currentY += lineHeight; + } + if (defScrollOffset_ > 0) { + renderer.drawText(BITTER_12_FONT_ID, popupX + popupW - kPopupPadding - 12, popupY + kPopupPadding, "^"); + } + if (defScrollOffset_ < defMaxScroll_) { + renderer.drawText(BITTER_12_FONT_ID, popupX + popupW - kPopupPadding - 12, + popupY + popupH - kPopupPadding - lineHeight, "v"); + } + } + + // CrumBLE 4.4 post-bisect: clear the bottom button-hints strip BEFORE drawing + // the overlay's hints. The word-select activity's full set of hints + // ("Lookup / Prev / Next" etc.) is still in the framebuffer underneath the + // popup (the popup is positioned with a 60px bottom margin so the hints + // area isn't covered). Without the wipe, those old labels remain visible + // alongside our overlay's "Back" hint. + // + // Hardware Up/Down already scroll the definition, so the overlay only needs + // a Back hint -- the rest of the slots stay blank. + const int hintsStripH = 50; + const int hintsStripY = renderer.getScreenHeight() - hintsStripH; + // Wipe the strip in the current page polarity so dark mode doesn't get a + // white flash under the hint buttons. The buttons themselves invert via + // the darkMode arg passed to drawButtonHints below. + const bool darkMode = ReaderUtils::readerDarkModeEnabled(); + renderer.fillRect(0, hintsStripY, renderer.getScreenWidth(), hintsStripH, darkMode); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, + /*allowInvertedText=*/false, darkMode); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} + +void DictionaryWordSelectActivity::closeDefinitionOverlay() { + defOverlay_ = false; + defOverlayLoading_ = false; + defOverlayNotFound_ = false; + defOverlayLowMemory_ = false; + defLines_.clear(); + defLines_.shrink_to_fit(); + defScrollOffset_ = 0; + // CrumBLE 4.4 post-bisect: aggressive cleanup to keep heap fragmentation + // from accumulating across consecutive lookups. wrappedText leaves a + // forest of small std::string allocations even after defLines_ is freed; + // dropping the font cache + dictionary state forces the heap allocator + // to coalesce neighbors and recover larger contiguous spans. + if (auto* fcm = renderer.getFontCacheManager()) { + fcm->clearCache(); + } + Dictionary::freeMemory(); + // Restore the captured selection screen and FAST-refresh -- the user sees + // their cursor exactly where it was. If capture was invalid, requestUpdate + // falls back to the full word-select render. + if (defOverlayCaptureValid_) { + renderer.restoreBwBuffer(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + defOverlayCaptureValid_ = false; + } else { + requestUpdate(); + } + // CrumBLE 4.4 post-bisect: post-dismiss silent-restart at a clean + // checkpoint. closeDefinitionOverlay just finished aggressive cleanup + // (font cache + dictionary memory freed, overlay state cleared) so heap + // is in the safest state we can reach without an actual restart. If + // MaxAlloc is still below a healthy floor, silent-restart with OpenLookup + // now -- before the user can fire another lookup that would either fail + // mid-flow (the buggy path) or show "Low memory". Post-boot opens + // text-selection on a fresh ~115 KB heap; cursor is reset to default + // (a known trade-off, but predictable and crash-free). + // CrumBLE 4.4 post-bisect: 13 KB threshold (just above the 10 KB wrap floor + // with ~3 KB margin). Lower than the original 22 KB to let users get 2-3 + // lookups between restarts instead of one. Trade-off: tighter per-lookup + // heap, so closer to wrap-fail risk -- if wrap-fail rate climbs, revert + // to 22 KB or land somewhere between (e.g. 16 KB). + constexpr uint32_t LOOKUP_POST_DISMISS_MIN_MAX_ALLOC = 13000; + if (ESP.getMaxAllocHeap() < LOOKUP_POST_DISMISS_MIN_MAX_ALLOC && !isContinuingFromSilentReboot()) { + LOG_INF("DICT", + "closeDefinitionOverlay: maxAlloc=%u below %u; silent-restart with OpenLookupAtWord('%s') " + "to give next lookup a fresh heap AND preserve cursor on this word", + ESP.getMaxAllocHeap(), LOOKUP_POST_DISMISS_MIN_MAX_ALLOC, defTargetWord_.c_str()); + silentRestartToReaderWithCursorWord(defTargetWord_.c_str()); + } } \ No newline at end of file diff --git a/src/activities/reader/DictionaryWordSelectActivity.h b/src/activities/reader/DictionaryWordSelectActivity.h index e53299ec..aba2afdc 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.h +++ b/src/activities/reader/DictionaryWordSelectActivity.h @@ -55,6 +55,19 @@ class DictionaryWordSelectActivity final : public Activity { void onExit() override; void render(RenderLock&&) override; + // CrumBLE 4.4 post-bisect: post-silent-restart restore hook. The reader's + // OpenDefinition / OpenLookupAtWord post-boot dispatch sets this to the + // word the user was looking up. On the first loop() tick after extractWords, + // the activity navigates the cursor to this word (if found on the current + // page); if openOverlay is true (OpenDefinition path), it then auto-opens + // the definition popup. If false (OpenLookupAtWord -- dismiss-time restart + // path), it stops after the cursor navigation so the user resumes on the + // word free to dismiss or pick another. + void setPendingDefinitionWord(std::string word, bool openOverlay = true) { + pendingDefinitionWord_ = std::move(word); + pendingOpenOverlay_ = openOverlay; + } + private: std::unique_ptr page; int fontId; @@ -77,6 +90,35 @@ class DictionaryWordSelectActivity final : public Activity { // contiguous highlight underline rather than the single-word box. int highlightAnchorWordIdx_ = -1; + // CrumBLE 4.4 post-bisect: inline definition overlay (kindle-style). + // When a Confirm in Lookup mode picks a word, we capture the current + // selection screen via storeBwBuffer (packbits-compressed 2-5 KB) and + // draw a centered definition popup on top. Back closes the popup and + // restoreBwBuffer redraws the selection screen underneath -- no full + // activity push, no silent restart, the user's selection cursor is + // preserved across the lookup. Replaces the prior + // DictionaryDefinitionActivity push pattern (which required a + // silent-restart for the second-and-later definition on low heap). + std::string pendingDefinitionWord_; // set by setPendingDefinitionWord; consumed on first loop() tick + bool pendingDefinitionCursorMoved_ = false; // phase gate: false=navigate first, true=now open overlay + bool pendingOpenOverlay_ = true; // true: auto-open overlay after cursor move; false: cursor only + bool defOverlay_ = false; + bool defOverlayLoading_ = false; // performing lookup; popup drawn empty + bool defOverlayNotFound_ = false; + bool defOverlayLowMemory_ = false; + bool defOverlayCaptureValid_ = false; // storeBwBuffer succeeded + std::string defTargetWord_; + std::vector defLines_; + int defScrollOffset_ = 0; + int defLinesPerPage_ = 0; + int defMaxScroll_ = 0; + + void openDefinitionOverlay(const std::string& word); + void closeDefinitionOverlay(); + void performDefinitionLookup(); + void wrapDefinition(const std::string& definition); + void renderDefinitionOverlay(); + void extractWords(); void mergeHyphenatedWords(); int findClosestWordIndexInRow(int rowIndex, int targetX) const; diff --git a/src/activities/reader/PrebakeManifestViewerActivity.cpp b/src/activities/reader/PrebakeManifestViewerActivity.cpp index 92993a98..7f66a2c3 100644 --- a/src/activities/reader/PrebakeManifestViewerActivity.cpp +++ b/src/activities/reader/PrebakeManifestViewerActivity.cpp @@ -160,10 +160,40 @@ void PrebakeManifestViewerActivity::render(RenderLock&&) { } else { fontLine1 = fontFamilyName(manifest_.fontFamily); } + // CrumBLE 4.4: human-readable font-size label. The manifest stores a + // FONT_SIZE enum index (built-in) or a step-into-range index (SD), neither + // of which means anything to the user on its own. Resolve to the actual + // point size and render as "N pt". Falls back to the raw step/range pair + // when the values are out of bounds for the current settings shape (e.g. + // a manifest baked by a future firmware with a wider enum). char fontLine2[32]; - std::snprintf(fontLine2, sizeof(fontLine2), "step %u / range %u", static_cast(manifest_.fontSize), - static_cast(manifest_.sdFontSizeRange)); - rows.push_back({"Font", fontLine1, fontLine2}); + uint8_t pointSize = 0; + bool gotPt = false; + if (manifest_.sdFontFamilyName[0] != '\0') { + if (manifest_.sdFontSizeRange < CrossPointSettings::SD_FONT_SIZE_RANGE_COUNT) { + pointSize = CrossPointSettings::getSdFontRangePointSize(manifest_.sdFontSizeRange, manifest_.fontSize); + gotPt = pointSize != 0; + } + } else { + if (manifest_.fontSize < CrossPointSettings::FONT_SIZE_COUNT) { + pointSize = CrossPointSettings::getReaderFontPointSize( + static_cast(manifest_.fontSize)); + gotPt = pointSize != 0; + } + } + if (gotPt) { + std::snprintf(fontLine2, sizeof(fontLine2), "%u pt", static_cast(pointSize)); + } else { + std::snprintf(fontLine2, sizeof(fontLine2), "step %u / range %u", + static_cast(manifest_.fontSize), + static_cast(manifest_.sdFontSizeRange)); + } + // CrumBLE 4.4: split into two labeled rows. Earlier layout used the + // continuation line for the point-size value, which read as a blank- + // label gap next to the family name. "Font" + "Font Size" makes both + // values self-explanatory at a glance. + rows.push_back({"Font", fontLine1}); + rows.push_back({"Font Size", fontLine2}); rows.push_back({"Orientation", orientationName(manifest_.orientation)}); rows.push_back({"Screen margin", std::to_string(manifest_.screenMargin) + " px"}); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index fcd522ca..ff21617a 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -5,6 +5,7 @@ #include #include +#include "../../SilentRestart.h" // CrumBLE 4.4: skip HALF refresh on first paint post-silent-reboot #include "MappedInputManager.h" namespace ReaderUtils { @@ -12,6 +13,15 @@ namespace ReaderUtils { constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; +// CrumBLE 4.4: reader dark mode (selective inversion). When enabled, the +// reader page is painted on a black background with white text/UI; EPUB +// content images render right-side up (no negative-photo effect). These +// helpers centralise the SETTINGS lookup so callers don't sprinkle +// SETTINGS.readerDarkMode reads through the draw pipeline. +inline bool readerDarkModeEnabled() { return SETTINGS.readerDarkMode != 0; } +inline uint8_t readerBackgroundColor() { return readerDarkModeEnabled() ? 0x00 : 0xFF; } +inline bool readerForegroundBlack() { return !readerDarkModeEnabled(); } + inline GfxRenderer::Orientation toRendererOrientation(const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: @@ -69,6 +79,17 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { } inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) { + // CrumBLE 4.4 post-bisect: on the first paint after a silent restart, + // skip the HALF refresh's panel-cycling flash. The panel is still + // holding the user's pre-restart frame (because we didn't paint + // anything before ESP.restart), so a FAST update transitions smoothly + // without a visible black/white cycle. Don't touch pagesUntilFullRefresh + // here so the normal HALF cadence resumes once the activity's + // pre-flight has cleared the continuation flag. + if (isContinuingFromSilentReboot()) { + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 1b730d5f..8a4a5949 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -355,8 +355,9 @@ void TxtReaderActivity::render(RenderLock&&) { } if (pageOffsets.empty()) { - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), ReaderUtils::readerForegroundBlack(), + EpdFontFamily::BOLD); renderer.displayBuffer(); return; } @@ -371,7 +372,7 @@ void TxtReaderActivity::render(RenderLock&&) { currentPageLines.clear(); loadPageAtOffset(offset, currentPageLines, nextOffset); - renderer.clearScreen(); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); renderPage(); // Save progress @@ -411,7 +412,7 @@ void TxtReaderActivity::renderPage() { break; } - renderer.drawText(cachedFontId, x, y, line.c_str()); + renderer.drawText(cachedFontId, x, y, line.c_str(), ReaderUtils::readerForegroundBlack()); } y += lineHeight; } @@ -441,7 +442,8 @@ void TxtReaderActivity::renderStatusBar() const { if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { title = txt->getTitle(); } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, false, + ReaderUtils::readerDarkModeEnabled()); } void TxtReaderActivity::saveProgress() const { @@ -751,7 +753,7 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx if (pageLines.empty()) return false; // Render lines to frame buffer (no displayBuffer call) - renderer.clearScreen(); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); int y = marginTop; for (const auto& line : pageLines) { if (!line.empty()) { @@ -766,7 +768,7 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx default: break; } - renderer.drawText(fontId, x, y, line.c_str()); + renderer.drawText(fontId, x, y, line.c_str(), ReaderUtils::readerForegroundBlack()); } y += lineHeight; } diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 7d4b2fd4..6ba70c99 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -26,33 +26,41 @@ constexpr int subtitleY = 738; } // namespace -void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) { +void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, + const bool foregroundBlack) { + // CrumBLE 4.4: foregroundBlack flips outline ink colour for the reader's + // dark mode. The drawLine overload that takes a width parameter is the one + // that accepts a colour bool, so use thickness=1 explicitly. // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); + renderer.drawLine(x + 1, y, x + battWidth - 3, y, 1, foregroundBlack); // Bottom line - renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); + renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1, 1, foregroundBlack); // Left line - renderer.drawLine(x, y + 1, x, y + rectHeight - 2); + renderer.drawLine(x, y + 1, x, y + rectHeight - 2, 1, foregroundBlack); // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2, 1, foregroundBlack); + renderer.drawPixel(x + battWidth - 1, y + 3, foregroundBlack); + renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4, foregroundBlack); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5, 1, foregroundBlack); } -void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) { - // Draw lightning bolt (white/inverted on black fill for visibility) - renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); - renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); - renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); - renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); - renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); - renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); - renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); - renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); +void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY, const bool boltInk) { + // CrumBLE 4.4: boltInk flips the bolt colour. Default false matches the + // original "white/inverted on black fill" semantics (charge bolt punched + // out of a black-filled battery icon). In dark mode the battery fill is + // already white, so the bolt becomes black via boltInk=true. + renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, 1, boltInk); + renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, 1, boltInk); + renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, 1, boltInk); + renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, 1, boltInk); + renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, 1, boltInk); + renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, 1, boltInk); + renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, 1, boltInk); + renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, 1, boltInk); } -void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const { +void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage, + const bool foregroundBlack) const { const bool charging = gpio.isUsbConnected(); const int maxFillWidth = rect.width - 5; @@ -72,14 +80,17 @@ void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t filledWidth = std::min(minFillForBolt, maxFillWidth); } - renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight); + renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight, foregroundBlack); if (charging) { - drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2); + // CrumBLE 4.4: bolt is the inverse of the fill colour so it stays visible + // against the now-foregroundBlack fill in either theme. + drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2, !foregroundBlack); } } -void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { +void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage, + const bool foregroundBlack) const { // Left aligned: icon on left, percentage number on right (reader mode). // CrumBLE: dropped the "%" suffix per user preference -- number alone // reads cleaner in the bottom-left status corner. @@ -88,12 +99,15 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo if (showPercentage) { const auto percentageText = std::to_string(percentage); - renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str(), + foregroundBlack); } const Rect iconRect{rect.x, y, rect.width, rect.height}; - drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height); - fillBatteryIcon(renderer, iconRect, percentage); + // CrumBLE 4.4: outline + fill take foregroundBlack so the battery icon + // flips white-on-black in the reader's dark mode. + drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height, foregroundBlack); + fillBatteryIcon(renderer, iconRect, percentage, foregroundBlack); } void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { @@ -138,7 +152,7 @@ void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const si } void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, - const char* btn4, const bool allowInvertedText) const { + const char* btn4, const bool allowInvertedText, const bool darkMode) const { const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); const bool invertText = allowInvertedText && orig_orientation == GfxRenderer::Orientation::PortraitInverted; renderer.setOrientation(invertText ? GfxRenderer::Orientation::PortraitInverted : GfxRenderer::Orientation::Portrait); @@ -154,15 +168,23 @@ void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; const char* labels[] = {btn1, btn2, btn3, btn4}; + // CrumBLE 4.4: dark-mode inverts each hint button -- black fill, white + // border, white text -- so the bottom strip blends with a dark-mode page + // instead of flashing as four bright white tiles. Only opted into by + // callers whose surrounding screen is already dark; default stays light. + const bool fillInk = darkMode; // true=black fill, false=white fill + const bool strokeInk = !darkMode; // true=black border, false=white border + const bool textBlack = !darkMode; // true=black text, false=white text + for (int i = 0; i < 4; i++) { // Only draw if the label is non-empty if (labels[i] != nullptr && labels[i][0] != '\0') { const int x = buttonPositions[invertText ? 3 - i : i]; - renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); - renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, fillInk); + renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, strokeInk); const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; - renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i], textBlack); } } @@ -660,7 +682,7 @@ void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount } Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message, int minTextWidth, - bool leftAlignText) const { + bool leftAlignText, HalDisplay::RefreshMode refreshMode) const { const auto& metrics = UITheme::getInstance().getMetrics(); const int marginX = metrics.popupMarginX; const int marginY = metrics.popupMarginY; @@ -693,7 +715,7 @@ Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message, int const int textX = leftAlignText ? (x + marginX) : (x + (w - actualTextWidth) / 2); const int textY = y + marginY + metrics.popupTextBaselineOffsetY; renderer.drawText(UI_12_FONT_ID, textX, textY, message, metrics.popupTextInverted, popupFontFamily); - renderer.displayBuffer(); + renderer.displayBuffer(refreshMode); return Rect{x, y, w, h}; } @@ -724,7 +746,12 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom, const int textYOffset, - const bool isPageBookmarked) const { + const bool isPageBookmarked, const bool darkMode) const { + // CrumBLE 4.4: ported per-element flip pattern from upstream CrossInk + // v1.3.2. foregroundBlack flips every draw colour; the bookmark notch is + // the lone exception -- it's a cutout into the bookmark body and must paint + // in the *background* colour, so it passes `darkMode` directly (= !fg). + const bool foregroundBlack = !darkMode; auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, @@ -764,8 +791,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); const int clearX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - maxProgressTextWidth; - renderer.fillRect(clearX, textY, maxProgressTextWidth, textHeight, false); - renderer.drawText(SMALL_FONT_ID, textX, textY, progressStr); + renderer.fillRect(clearX, textY, maxProgressTextWidth, textHeight, darkMode); + renderer.drawText(SMALL_FONT_ID, textX, textY, progressStr, foregroundBlack); } // Draw Progress Bar @@ -782,7 +809,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c } const int barWidth = progressBarMaxWidth * progress / 100; renderer.fillRect(orientedMarginLeft, progressBarY, barWidth, ((SETTINGS.statusBarProgressBarThickness + 1) * 2), - true); + foregroundBlack); } // Bookmark icon: drawn at the far left of the status bar when the current page is bookmarked. @@ -798,10 +825,10 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c // +5 compensates for the battery nub drawn above the rect origin by drawBatteryLeft, // which shifts the battery body's visual center below the mathematical rect center. const int bmY = textY + (metrics.batteryHeight - bmIconH) / 2 + 5; - renderer.fillRect(bmX, bmY, bmIconW, bmIconH, true); + renderer.fillRect(bmX, bmY, bmIconW, bmIconH, foregroundBlack); const int xNotch[3] = {bmX, bmX + bmIconW, bmX + bmIconW / 2}; const int yNotch[3] = {bmY + bmIconH, bmY + bmIconH, bmY + bmIconH - bmNotchDepth}; - renderer.fillPolygon(xNotch, yNotch, 3, false); + renderer.fillPolygon(xNotch, yNotch, 3, darkMode); } // Draw Battery (in the leftmost slot of the status bar, right after the @@ -811,7 +838,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c const int batteryX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1 + bmTotalWidth; if (SETTINGS.statusBarBattery) { GUI.drawBatteryLeft(renderer, Rect{batteryX, textY, metrics.batteryWidth, metrics.batteryHeight}, - showBatteryPercentage); + showBatteryPercentage, foregroundBlack); } // CrumBLE: BT status icon removed -- the connection-state tracking was @@ -828,7 +855,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c // Position to the left of the progress text (with a small gap) const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth; - renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf); + renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf, foregroundBlack); } } @@ -867,7 +894,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c renderer.drawText(SMALL_FONT_ID, titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + (availableTitleSpace - titleWidth) / 2, - textY, title.c_str()); + textY, title.c_str(), foregroundBlack); } } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 5cacc619..7078cdf1 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -189,13 +191,14 @@ class BaseTheme { // Component drawing methods void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const; - void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, - bool showPercentage = true) const; // Left aligned (reader mode) + void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true, + bool foregroundBlack = true) const; // Left aligned (reader mode) void drawBatteryRight(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const; // Right aligned (UI headers) - virtual void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const; + virtual void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage, + bool foregroundBlack = true) const; virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, - const char* btn4, bool allowInvertedText = false) const; + const char* btn4, bool allowInvertedText = false, bool darkMode = false) const; virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, @@ -232,11 +235,15 @@ class BaseTheme { // the trailing dots appear/disappear to its right without shifting // the word itself. virtual Rect drawPopup(const GfxRenderer& renderer, const char* message, int minTextWidth = 0, - bool leftAlignText = false) const; + bool leftAlignText = false, + HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; + // CrumBLE 4.4: darkMode flips foreground to white and background to black + // (for the reader's dark mode); default false matches all prior callers. virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom = 0, - const int textYOffset = 0, const bool isPageBookmarked = false) const; + const int textYOffset = 0, const bool isPageBookmarked = false, + const bool darkMode = false) const; virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; @@ -251,8 +258,9 @@ class BaseTheme { // Shared constants and helpers for battery drawing (used by all themes) static constexpr int batteryPercentSpacing = 4; - static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight); - static void drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY); + static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, + bool foregroundBlack = true); + static void drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY, bool boltInk = false); // CrumBLE: status-bar BT icon removed -- the always-dotted variant misled // users when the remote disconnected, and the state-tracking variant diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index af1769a9..f2e0b178 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -93,22 +93,23 @@ const uint8_t* LyraTheme::iconForName(UIIcon icon, uint32_t size) { return nullptr; } -void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const { +void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage, + const bool foregroundBlack) const { const bool charging = gpio.isUsbConnected(); if (charging) { // Solid fill when charging so lightning bolt is visible - renderer.fillRect(rect.x + 2, rect.y + 2, rect.width - 5, rect.height - 4); - drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2); + renderer.fillRect(rect.x + 2, rect.y + 2, rect.width - 5, rect.height - 4, foregroundBlack); + drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2, !foregroundBlack); } else { if (percentage > 10) { - renderer.fillRect(rect.x + 2, rect.y + 2, 3, rect.height - 4); + renderer.fillRect(rect.x + 2, rect.y + 2, 3, rect.height - 4, foregroundBlack); } if (percentage > 40) { - renderer.fillRect(rect.x + 6, rect.y + 2, 3, rect.height - 4); + renderer.fillRect(rect.x + 6, rect.y + 2, 3, rect.height - 4, foregroundBlack); } if (percentage > 70) { - renderer.fillRect(rect.x + 10, rect.y + 2, 3, rect.height - 4); + renderer.fillRect(rect.x + 10, rect.y + 2, 3, rect.height - 4, foregroundBlack); } } } @@ -393,7 +394,7 @@ void LyraTheme::drawListWithMetrics(const GfxRenderer& renderer, Rect rect, int } void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, - const char* btn4, const bool allowInvertedText) const { + const char* btn4, const bool allowInvertedText, const bool darkMode) const { const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); const bool invertText = allowInvertedText && orig_orientation == GfxRenderer::Orientation::PortraitInverted; renderer.setOrientation(invertText ? GfxRenderer::Orientation::PortraitInverted : GfxRenderer::Orientation::Portrait); @@ -410,22 +411,30 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; const char* labels[] = {btn1, btn2, btn3, btn4}; + // CrumBLE 4.4: dark-mode hint buttons. Default keeps the existing + // white-fill / black-outline / black-text palette. In dark mode the + // fill flips to black, outline + text to white -- visually consistent + // with the dark-mode page underneath. + const Color buttonFill = darkMode ? Color::Black : Color::White; + const bool strokeInk = !darkMode; + const bool textBlack = !darkMode; + for (int i = 0; i < 4; i++) { const int x = buttonPositions[invertText ? 3 - i : i]; if (labels[i] != nullptr && labels[i][0] != '\0') { // Draw the filled background and border for a FULL-sized button - renderer.fillRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, cornerRadius, Color::White); + renderer.fillRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, cornerRadius, buttonFill); renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, - false, true); + false, strokeInk); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; - renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i], textBlack); } else { // Draw the filled background and border for a SMALL-sized button const int smallButtonY = invertText ? 0 : pageHeight - smallButtonHeight; - renderer.fillRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, cornerRadius, Color::White); + renderer.fillRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, cornerRadius, buttonFill); renderer.drawRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, 1, cornerRadius, true, true, false, - false, true); + false, strokeInk); } } diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index d614dcd3..9cdd0634 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -82,7 +82,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, class LyraTheme : public BaseTheme { public: // Component drawing methods - void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override; + void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage, + bool foregroundBlack = true) const override; void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override; void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel = nullptr) const override; @@ -95,7 +96,7 @@ class LyraTheme : public BaseTheme { bool highlightValue, const std::function& rowDimmed = nullptr, const std::function& isHeader = nullptr) const override; void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4, - bool allowInvertedText = false) const override; + bool allowInvertedText = false, bool darkMode = false) const override; void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override; void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, diff --git a/src/components/themes/minimal/MinimalTheme.cpp b/src/components/themes/minimal/MinimalTheme.cpp index ad2d4237..a543cedd 100644 --- a/src/components/themes/minimal/MinimalTheme.cpp +++ b/src/components/themes/minimal/MinimalTheme.cpp @@ -432,7 +432,7 @@ void MinimalTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCoun void MinimalTheme::setHomeButtonHintSelection(const int selectedIndex) { homeButtonHintSelection = selectedIndex; } void MinimalTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, - const char* btn4, const bool allowInvertedText) const { + const char* btn4, const bool allowInvertedText, const bool darkMode) const { const GfxRenderer::Orientation origOrientation = renderer.getOrientation(); const bool invertText = allowInvertedText && origOrientation == GfxRenderer::Orientation::PortraitInverted; renderer.setOrientation(invertText ? GfxRenderer::Orientation::PortraitInverted : GfxRenderer::Orientation::Portrait); @@ -451,22 +451,31 @@ void MinimalTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, cons const int selectedIndex = homeButtonHintSelection; homeButtonHintSelection = -1; + // CrumBLE 4.4: dark-mode-aware hint buttons. Default keeps the existing + // white/gray/black palette; in dark mode the button fill flips to black, + // the outline to white, and the label text to white -- consistent with + // dark-mode reader page underneath. Selection still shifts contrast. + const Color defaultFill = darkMode ? Color::Black : Color::White; + const Color selectedFill = darkMode ? Color::DarkGray : Color::LightGray; + const bool strokeInk = !darkMode; + const bool textBlack = !darkMode; + for (int i = 0; i < 4; i++) { const int x = buttonPositions[invertText ? 3 - i : i]; const bool hasLabel = labels[i] != nullptr && labels[i][0] != '\0'; if (hasLabel) { - const Color background = i == selectedIndex ? Color::LightGray : Color::White; + const Color background = i == selectedIndex ? selectedFill : defaultFill; renderer.fillRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, kButtonCornerRadius, background); renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, kButtonCornerRadius, true, true, - false, false, true); + false, false, strokeInk); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; - renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i], textBlack); } else { const int smallButtonY = invertText ? 0 : pageHeight - smallButtonHeight; - renderer.fillRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, kButtonCornerRadius, Color::White); + renderer.fillRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, kButtonCornerRadius, defaultFill); renderer.drawRoundedRect(x, smallButtonY, buttonWidth, smallButtonHeight, 1, kButtonCornerRadius, true, true, - false, false, true); + false, false, strokeInk); } } diff --git a/src/components/themes/minimal/MinimalTheme.h b/src/components/themes/minimal/MinimalTheme.h index 565a9d57..add279fa 100644 --- a/src/components/themes/minimal/MinimalTheme.h +++ b/src/components/themes/minimal/MinimalTheme.h @@ -41,7 +41,7 @@ class MinimalTheme : public LyraTheme { bool highlightValue, const std::function& rowDimmed = nullptr, const std::function& isHeader = nullptr) const override; void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4, - bool allowInvertedText = false) const override; + bool allowInvertedText = false, bool darkMode = false) const override; void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, const std::function& storeCoverBuffer, const BookReadingStats* stats = nullptr, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 8d12d419..26d506fe 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -442,7 +442,7 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item } void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, - const char* btn4, const bool allowInvertedText) const { + const char* btn4, const bool allowInvertedText, const bool darkMode) const { const GfxRenderer::Orientation origOrientation = renderer.getOrientation(); const bool invertText = allowInvertedText && origOrientation == GfxRenderer::Orientation::PortraitInverted; renderer.setOrientation(invertText ? GfxRenderer::Orientation::PortraitInverted : GfxRenderer::Orientation::Portrait); @@ -471,11 +471,18 @@ void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const std::string upText = (rightInnerLabel && rightInnerLabel[0] != '\0') ? std::string(rightInnerLabel) : ""; const std::string downText = (rightOuterLabel && rightOuterLabel[0] != '\0') ? std::string(rightOuterLabel) : ""; + // CrumBLE 4.4: dark-mode hint groups. Default keeps the white wipe + black + // outline + black text. In dark mode the wipe flips to black, outline + + // text to white so the strip matches a dark-mode page underneath. + const bool fillInk = darkMode; + const bool strokeInk = !darkMode; + const bool textBlack = !darkMode; + // Ensure button hints always "win" visually even if other elements accidentally render into this area. - renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, false); - renderer.fillRect(rightGroupX, hintY, groupWidth, hintHeight, false); + renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, fillInk); + renderer.fillRect(rightGroupX, hintY, groupWidth, hintHeight, fillInk); - renderer.drawRoundedRect(leftGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + renderer.drawRoundedRect(leftGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, strokeInk); const int selectWidth = renderer.getTextWidth(kGuideFontId, selectText.c_str(), EpdFontFamily::REGULAR); const int downWidth = renderer.getTextWidth(kGuideFontId, downText.c_str(), EpdFontFamily::REGULAR); constexpr int innerEdgePadding = 16; @@ -486,14 +493,14 @@ void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const int downX = rightGroupX + groupWidth - innerEdgePadding - downWidth; if (!backDisabled) { - renderer.drawText(kGuideFontId, backX, textY, backLabel.c_str(), true, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, backX, textY, backLabel.c_str(), textBlack, EpdFontFamily::REGULAR); } - renderer.drawText(kGuideFontId, selectX, textY, selectText.c_str(), true, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, selectX, textY, selectText.c_str(), textBlack, EpdFontFamily::REGULAR); - renderer.drawRoundedRect(rightGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + renderer.drawRoundedRect(rightGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, strokeInk); - renderer.drawText(kGuideFontId, upX, textY, upText.c_str(), true, EpdFontFamily::REGULAR); - renderer.drawText(kGuideFontId, downX, textY, downText.c_str(), true, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, upX, textY, upText.c_str(), textBlack, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, downX, textY, downText.c_str(), textBlack, EpdFontFamily::REGULAR); renderer.setOrientation(origOrientation); } diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index b1103e04..23f3ce5d 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -95,6 +95,6 @@ class RoundedRaffTheme : public BaseTheme { const std::function& rowDimmed = nullptr, const std::function& isHeader = nullptr) const override; void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4, - bool allowInvertedText = false) const override; + bool allowInvertedText = false, bool darkMode = false) const override; bool homeMenuShowsContinueReading() const { return true; } }; From 86e68af9de8386cf24c3891eca66004fcab4d7d5 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:53:55 -0700 Subject: [PATCH 06/10] feat(reader,stability): defensive save + BT/heap pre-flights + stats split Reader-activity stability + reading-stats infrastructure: - Defensive save-on-exit: if a render-time progress save failed under heap fragmentation (FsFile alloc failed mid-page), a fallback save fires at reader exit on a usually-cleaner heap. Eliminates the user-fatal "stuck on last saved page" case where only deleting the book unblocked it. - BT enable pre-flight floor 40K -> 55K (X3 + X4). Field-tested under SD-card-font + dark-mode where NimBLE+IINE-subscribe consumed 66K of free heap; the prior 40K floor let pre-flight pass at MaxAlloc=43K and the next page deserialize crashed with TextBlock alloc < needed. - BT scan results capped at MAX_SCAN_RESULTS=12 with weakest-RSSI eviction + pre-scan heap gate; X3 first-pair was OOM-crashing on crowded RF environments where the 22+ device std::string allocs ran the post-scan picker out of heap. - Reading stats split (port from CrossInk v1.3.3): Clear Reading Cache preserves per-book stats.bin; new Delete Book's Reading Stats menu entry in the reader long-press menu and file-browser long-press menu. BookCacheUtils gains preserveStateFiles/restorePreservedFiles helpers + clearBookCacheDirectoryPreservingStats public entry point. - [STATS]/[ERS] serial log demote: BookReadingStats::exists/load and the ERS progress-load path now Storage.exists() short-circuit before the noisy openFileForRead call, so never-opened books no longer spam the serial output during home navigation and carousel pre-render. - "Going home" popup: heap-conditional FAST/HALF refresh. On a tight heap the FAST_REFRESH LUT produces a dim/partially-inverted popup (BW backup compression failures leave the framebuffer state divergent from the controller's view). Below 32K MaxAlloc, falls back to HALF_REFRESH for a clean render at the cost of ~770ms. --- lib/hal/BluetoothHIDManager.cpp | 69 ++- lib/hal/BluetoothHIDManager.h | 11 + src/SilentRestart.h | 106 +++++ src/activities/Activity.cpp | 15 +- .../network/CrossPointWebServerActivity.cpp | 21 + src/activities/reader/BookReadingStats.cpp | 37 +- src/activities/reader/BookReadingStats.h | 5 + .../reader/DictionaryDefinitionActivity.cpp | 157 ++++--- src/activities/reader/EpubReaderActivity.cpp | 404 ++++++++++++++++-- src/activities/reader/EpubReaderActivity.h | 15 + .../reader/EpubReaderMenuActivity.cpp | 31 +- .../reader/EpubReaderMenuActivity.h | 1 + src/activities/reader/GlobalReadingStats.cpp | 16 + src/activities/reader/GlobalReadingStats.h | 5 + src/activities/reader/ReaderActivity.cpp | 13 +- .../settings/BluetoothSettingsActivity.cpp | 45 +- .../settings/ClearCacheActivity.cpp | 7 +- src/util/BookCacheUtils.cpp | 95 ++++ src/util/BookCacheUtils.h | 6 + 19 files changed, 931 insertions(+), 128 deletions(-) diff --git a/lib/hal/BluetoothHIDManager.cpp b/lib/hal/BluetoothHIDManager.cpp index eb27d5de..25382130 100644 --- a/lib/hal/BluetoothHIDManager.cpp +++ b/lib/hal/BluetoothHIDManager.cpp @@ -229,7 +229,10 @@ bool BluetoothHIDManager::enable() { } LOG_INF("BT", "Enabling Bluetooth..."); - + // CrumBLE 4.4 post-bisect: fresh auto-reconnect budget per BT cycle. + _autoReconnectConsumedThisCycle = false; + _autoReconnectPending = false; + // CRITICAL: Disable WiFi when enabling Bluetooth // ESP32-C3 cannot have both WiFi and BLE enabled simultaneously if (WiFi.getMode() != WIFI_OFF) { @@ -421,17 +424,49 @@ void BluetoothHIDManager::onScanResult(NimBLEAdvertisedDevice* advertisedDevice) return; } } - + + // CrumBLE 4.4: cap discovered devices. On a busy RF environment we see 20+ + // results and the post-scan picker activity allocates a std::vector with a "name (addr) RSSI" entry per device -- on the X3's NimBLE- + // squeezed heap (NimBLE eats ~58 KB), even 20 entries hit the picker's + // grow-on-build with MaxAlloc < entry size and abort(). Cap at 12 so the + // picker fits comfortably; weakest-RSSI entry is evicted to keep the most + // promising ones. HID devices (remotes) get eviction priority over non-HID. + constexpr size_t MAX_SCAN_RESULTS = 12; + if (_discoveredDevices.size() >= MAX_SCAN_RESULTS) { + // Find weakest entry to evict. Prefer evicting non-HID over HID so a real + // remote isn't dropped in favour of nearby phones/headphones. + auto pickEvictionTarget = [this](bool requireNonHid) { + auto worst = _discoveredDevices.end(); + int worstRssi = INT32_MAX; + for (auto it = _discoveredDevices.begin(); it != _discoveredDevices.end(); ++it) { + if (requireNonHid && it->isHID) continue; + if (it->rssi < worstRssi) { worstRssi = it->rssi; worst = it; } + } + return worst; + }; + auto victim = pickEvictionTarget(/*requireNonHid=*/true); + if (victim == _discoveredDevices.end()) { + victim = pickEvictionTarget(/*requireNonHid=*/false); + } + // Only evict if the new device beats the weakest -- otherwise just drop. + if (victim == _discoveredDevices.end() || rssi <= victim->rssi) { + LOG_DBG("BT", "Scan cap reached, dropping weaker new device RSSI=%d", rssi); + return; + } + _discoveredDevices.erase(victim); + } + // Add new device BluetoothDevice device; device.address = address; device.name = name.empty() ? "Unknown" : name; device.rssi = rssi; device.isHID = isHID; - + _discoveredDevices.push_back(device); - LOG_DBG("BT", "Found device: %s (%s) RSSI:%d HID:%d", + LOG_DBG("BT", "Found device: %s (%s) RSSI:%d HID:%d", device.name.c_str(), device.address.c_str(), rssi, isHID); } @@ -751,7 +786,13 @@ void BluetoothHIDManager::noteClientDisconnect(int reason) { // A drop in the first SETTLE_MS is almost always bonding/encryption renegotiation // on the first connect (the link comes back on its own within a second). Earlier // this fired a false "BT couldn't stay connected" alert every first connect. - if (since < SETTLE_MS) { + // + // CrumBLE 4.4 post-bisect: HCI reason 520 (0x208 = BLE supervision timeout) is + // unambiguous — it's the "controller timed the link out under heap pressure" + // case, NOT renegotiation. Fall through to the auto-reconnect branch even + // inside the settle window so a drop at ~2s (observed empirically) still + // triggers the one-shot retry. + if (since < SETTLE_MS && reason != 520) { LOG_DBG("BT", "Link dropped %lums after connect (reason %d); within settle window, no alert", since, reason); return; @@ -760,9 +801,21 @@ void BluetoothHIDManager::noteClientDisconnect(int reason) { // link out under heap pressure" case (HCI 0x08 / reason 520). Surface it so the // user isn't left wondering why Bluetooth silently went away. if (since < EARLY_DISCONNECT_MS) { - LOG_INF("BT", "Link dropped %lums after connect (reason %d); flagging low-memory connect alert", - since, reason); - _connectionLostAlertPending = true; + // CrumBLE 4.4 post-bisect: an early reason-520 drop is the + // "supervision timeout during post-connect render" race. Fire a + // one-shot auto-reconnect (reader polls takeAutoReconnectRequest() + // and calls connectToDevice() again). If the second attempt also + // drops, the alert path takes over -- no infinite retry loop. + if (!_autoReconnectConsumedThisCycle) { + LOG_INF("BT", "Link dropped %lums after connect (reason %d); queueing one-shot auto-reconnect", + since, reason); + _autoReconnectPending = true; + _autoReconnectConsumedThisCycle = true; + } else { + LOG_INF("BT", "Link dropped %lums after connect (reason %d); auto-reconnect already used, flagging alert", + since, reason); + _connectionLostAlertPending = true; + } } } diff --git a/lib/hal/BluetoothHIDManager.h b/lib/hal/BluetoothHIDManager.h index a26aa110..b5a7943b 100644 --- a/lib/hal/BluetoothHIDManager.h +++ b/lib/hal/BluetoothHIDManager.h @@ -155,6 +155,15 @@ class BluetoothHIDManager { _connectionLostAlertPending = false; return v; } + // CrumBLE 4.4 post-bisect: one-shot auto-reconnect request. Set by + // noteClientDisconnect when an early supervision-timeout drop is + // observed. The reader's loop polls this and calls connectToDevice() + // again to spare the user a manual reconnect. Cleared on consume. + bool takeAutoReconnectRequest() { + bool v = _autoReconnectPending; + _autoReconnectPending = false; + return v; + } private: BluetoothHIDManager(); @@ -175,6 +184,8 @@ class BluetoothHIDManager { unsigned long _lastConnectMillis = 0; // when a link was last established bool _intentionalDisconnect = false; // suppress the alert for disconnects we initiate bool _connectionLostAlertPending = false; // one-shot: a link dropped unexpectedly soon after connecting + bool _autoReconnectPending = false; // one-shot: an early drop has fired; reader should retry connect + bool _autoReconnectConsumedThisCycle = false; // gate so we only auto-retry once per enable() cycle // A drop in the first SETTLE_MS after connect is almost always benign bonding/ // encryption renegotiation -- the link is then re-established cleanly and the // user never notices unless we surface a (spurious) alert. Drops in diff --git a/src/SilentRestart.h b/src/SilentRestart.h index 2c5d73cd..c6b3f301 100644 --- a/src/SilentRestart.h +++ b/src/SilentRestart.h @@ -1,5 +1,7 @@ #pragma once +#include + // ESP.restart() with an RTC_NOINIT flag that survives the reboot, so setup() // skips the boot splash and routes straight to a destination. Used to clear // heap fragmentation accumulated during a wifi session. @@ -13,3 +15,107 @@ void silentRestartToFileTransfer();// goes straight back to File Transfer activi // Valid values: 0 = no hint, 1 = JOIN_NETWORK, 2 = CREATE_HOTSPOT. void setSilentRebootFtModeHint(uint32_t mode); uint32_t consumeSilentRebootFtModeHint(); + +// CrumBLE 4.4 task #48: quick-restart on natural pauses. The pre-boot +// action runs once the activity stack lands back on the reader, giving +// the operation a fresh post-defrag heap to work with. +// +// ReaderPostBootAction::EnableBt +// -- ESP.restart, route to reader, then trigger BT enable via the +// reader's Quick Connect path (which drops settings cache + +// page-heap reserve before enabling NimBLE). Replaces the +// "Refusing to enable Bluetooth: free heap below threshold" +// dead-end on the second-and-later BT enable per boot. +// ReaderPostBootAction::OpenLookup +// -- ESP.restart, route to reader, then push the Lookup activity. +// Replaces the "low memory alert" Lookup currently produces +// when MaxAlloc is under ~32 KB. +// ReaderPostBootAction::OpenHighlight (task #62) +// -- ESP.restart, route to reader, then re-enter the AddHighlight +// menu action. Replaces the "low memory" alert Highlight +// previously produced when MaxAlloc dipped below ~32 KB -- +// Highlight reuses Lookup's WordInfo allocation path so the +// same heap budget applies; same recovery pattern fits. +// ReaderPostBootAction::ResumeAtSpine (task #63) +// -- ESP.restart, route to reader, then resume at a target spine +// (carried in a separate RTC var since the action enum has no +// payload). Used by the chapter-transition pre-flight when +// MaxAlloc is too low to safely run section createSectionFile / +// loadSectionFile (peaks ~13-20 KB). Post-boot the section +// rebuild happens on a fresh ~115 KB heap; first paint uses HALF +// refresh since the panel pre-restart held the previous chapter's +// page and we want a clean transition to the new chapter. +enum class ReaderPostBootAction : uint8_t { + None = 0, + EnableBt = 1, + OpenLookup = 2, + OpenHighlight = 3, + ResumeAtSpine = 4, + // CrumBLE 4.4 post-bisect: jump straight to the definition of the word + // the user just tapped. Word is carried in silentRebootDefinitionWord + // (RTC slot); consume via consumePendingDefinitionWord(). + OpenDefinition = 5, + // CrumBLE 4.4 post-bisect: route post-boot dispatch into Reading Stats. + // Used when the user selects Reading Stats while BT is connected and + // heap is too fragmented to safely run NimBLE teardown -- silent-restart + // first, then open Stats against a fresh ~115 KB heap (BT cold). + OpenReadingStats = 6, + // CrumBLE 4.4 post-bisect: like OpenDefinition (word in silentRebootDefinitionWord), + // but post-boot does NOT auto-open the definition popup -- just navigates + // the cursor to the word and stops. Used by the dismiss-time silent-restart + // path so the user resumes on the same word they just looked up without + // the popup reappearing. + OpenLookupAtWord = 7, + // CrumBLE 4.4: KOReader Sync TLS handshake needs ~55 KB contiguous heap, + // and mid-reading heap can be way under that (typical: 16-19 KB after + // section + BT). When the user opens Sync from the menu under tight heap, + // silent-restart first, then re-trigger the SYNC menu action on a fresh + // ~115 KB heap (BT cold) -- mirrors the OpenReadingStats pre-flight. + OpenKoSync = 8, +}; + +// Restart + queue OpenDefinition with a word string. Allocation-free. +void silentRestartToReaderWithDefinition(const char* word); + +// Restart + queue OpenLookupAtWord with a word string. Allocation-free. Same +// word-carrying path as silentRestartToReaderWithDefinition, but post-boot +// skips auto-opening the popup -- only moves cursor to the word. +void silentRestartToReaderWithCursorWord(const char* word); + +// Read-and-clear the queued word string. Returns nullptr if no +// definition is queued (or the queued word was empty). +const char* consumePendingDefinitionWord(); + +// Variant for ResumeAtSpine: caller specifies the target spine the post-boot +// resume should land on. The reader resumes that spine, page 0. Path-scoped +// via APP_STATE.openEpubPath -- no separate book hash needed because if the +// user opens a different book post-restart the override is just ignored +// (currentSpineIndex stays at whatever progress.bin had). +void silentRestartToReaderResumingAtSpine(int targetSpine); + +// Read-and-clear the queued resume spine. EpubReaderActivity calls this in +// onEnter after the progress.bin load; if a value was queued it overrides +// currentSpineIndex and forces nextPageNumber = 0. Returns -1 if no resume +// was queued. +int consumePendingResumeSpine(); + +// Restart + queue the action. Allocation-free (just RTC writes + ESP.restart) +// so it can be called from low-heap contexts. Caller may draw a contextual +// popup before calling -- the e-ink panel retains the last frame across the +// reboot, so the user sees that popup until the reader paints. +void silentRestartToReaderWithAction(ReaderPostBootAction action); + +// Read-and-clear the queued post-boot action. EpubReaderActivity calls +// this once it has finished its own first-paint setup; the returned +// action then dispatches on the next loop tick. +ReaderPostBootAction consumeReaderPostBootAction(); + +// CrumBLE 4.4 task #50: process-lifetime flag set true at setup() when +// the current boot resumes from a silent restart. Used by activities +// to skip cold-boot ceremony that would clobber the pre-reboot popup +// the panel is still holding -- ReaderActivity skips its "Loading..." +// popup, EpubReaderActivity forces FAST instead of HALF refresh on +// its first paint. Does NOT auto-clear so multiple callsites can +// query it during the boot sequence. +bool isContinuingFromSilentReboot(); +void clearSilentRebootContinuationFlag(); diff --git a/src/activities/Activity.cpp b/src/activities/Activity.cpp index 18c66f9f..791f7f40 100644 --- a/src/activities/Activity.cpp +++ b/src/activities/Activity.cpp @@ -1,5 +1,6 @@ #include "Activity.h" +#include // ESP.getMaxAllocHeap() #include #include @@ -30,7 +31,19 @@ void Activity::exitToHomeWithPopup() { // this, the reader's long-tail exit (BLE shutdown, session save, // activity replace, carousel render) leaves the panel frozen on // the last reader page for ~700 ms. - GUI.drawPopup(renderer, tr(STR_GOING_HOME)); + // + // CrumBLE 4.4: on a tight heap, FAST_REFRESH's custom LUT can produce + // a dim / partially-inverted popup -- the controller's view of the + // panel state diverges from the framebuffer when BW backup + // compression or display-buffer allocations fail. Fall back to + // HALF_REFRESH in that case: ~770 ms instead of instant, but the + // popup actually renders correctly. Threshold mirrors other + // heap-pre-flight checks in the reader (~32 KB MaxAlloc floor). + constexpr uint32_t kGoingHomePopupHealthyMaxAlloc = 32u * 1024u; + const auto popupRefresh = ESP.getMaxAllocHeap() >= kGoingHomePopupHealthyMaxAlloc + ? HalDisplay::FAST_REFRESH + : HalDisplay::HALF_REFRESH; + GUI.drawPopup(renderer, tr(STR_GOING_HOME), /*minTextWidth=*/0, /*leftAlignText=*/false, popupRefresh); // CrumBLE: tear NimBLE down synchronously BEFORE the Home transition. Home's // Flow shelf renders on a separate task and would otherwise race the reader's // deferred BLE disable -- rendering while NimBLE still holds ~58 KB, which diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index c11409a0..187cd4a9 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,26 @@ void CrossPointWebServerActivity::onEnter() { // automatically when the reader resumes. No-op for built-in fonts. sdFontSystem.releaseLoadedFont(renderer); + // CrumBLE 4.4: release the page-DOM heap reserve (commit 403de139, 18 KB + // chunk lazily acquired on first chapter open to guarantee the + // deserialize allocator a contiguous slot under BT-induced fragmentation). + // It was previously only released for BT-enable, so a reader -> home -> FT + // session arrived here with 18 KB of contiguous heap permanently locked + // away -- which exactly matched the WS upload heap-pressure regression + // (MinFree bottoming at ~1-4 KB mid-upload instead of the pre-v4.3 + // ~12-15 KB it ran at). FT doesn't read pages; releasing here reclaims + // the headroom for WS/SD/lwIP. The reserve will be lazily re-acquired by + // Section::loadPageFromSectionFile the next time the user opens a book, + // gated on MaxAlloc > 30 KB so it never wedges a tight-heap restart. + // releasePageHeapReserveForBtEnable() is just the misleadingly-named + // unconditional release primitive -- nothing BT-specific about it. + if (Section::pageHeapReserveHeld()) { + LOG_INF("WEBACT", "Releasing page-DOM heap reserve (18 KB) for FT activity"); + Section::releasePageHeapReserveForBtEnable(); + LOG_INF("WEBACT", "Free heap after page-reserve release: %d bytes (maxAlloc %d)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } + // CrumBLE: the in-RAM LibraryIndex (up to tens of KB for a big library) is // dead weight while the web server runs and is a major reason free heap sits // at only ~25 KB here. Release it now for serving headroom; onExit marks it diff --git a/src/activities/reader/BookReadingStats.cpp b/src/activities/reader/BookReadingStats.cpp index 447ca7b8..720206d8 100644 --- a/src/activities/reader/BookReadingStats.cpp +++ b/src/activities/reader/BookReadingStats.cpp @@ -26,20 +26,27 @@ static constexpr int STATS_FILE_SIZE = 12; } // namespace bool BookReadingStats::exists(const std::string& cachePath) { - // Use the existing "open for read; close if successful" idiom -- there's no - // dedicated existence API on HalStorage, but a failed open is a fast no-op. - FsFile f; - if (!Storage.openFileForRead("STATS", cachePath + "/stats.bin", f)) { - return false; - } - f.close(); - return true; + // CrumBLE 4.4: use HalStorage::exists directly. The previous open-then-close + // idiom routed through SDCardManager::openFileForRead, which emits + // "[STATS] File does not exist: ..." to Serial for every miss -- and a + // "miss" is the normal case for any book the user hasn't opened yet. + // Across home navigation + carousel pre-render that's dozens of noisy + // lines per session. Storage.exists is a quiet sd.exists() check. + return Storage.exists((cachePath + "/stats.bin").c_str()); } BookReadingStats BookReadingStats::load(const std::string& cachePath) { BookReadingStats stats; + // CrumBLE 4.4: quiet existence check first -- avoids the + // "[STATS] File does not exist" Serial spam for new/unread books. We still + // call openFileForRead afterwards so a true I/O failure (file present but + // unreadable) is logged as the genuine error it is. + const std::string statsPath = cachePath + "/stats.bin"; + if (!Storage.exists(statsPath.c_str())) { + return stats; + } FsFile f; - if (!Storage.openFileForRead("STATS", cachePath + "/stats.bin", f)) { + if (!Storage.openFileForRead("STATS", statsPath, f)) { return stats; } uint8_t data[STATS_FILE_SIZE] = {}; @@ -104,3 +111,15 @@ void BookReadingStats::save(const std::string& cachePath) const { f.write(data, STATS_FILE_SIZE); f.close(); } + +bool BookReadingStats::remove(const std::string& cachePath) { + const std::string statsPath = cachePath + "/stats.bin"; + if (!Storage.exists(statsPath.c_str())) { + return true; + } + if (!Storage.remove(statsPath.c_str())) { + LOG_ERR("STATS", "Could not delete stats.bin"); + return false; + } + return true; +} diff --git a/src/activities/reader/BookReadingStats.h b/src/activities/reader/BookReadingStats.h index 72c4837e..e781e32d 100644 --- a/src/activities/reader/BookReadingStats.h +++ b/src/activities/reader/BookReadingStats.h @@ -23,6 +23,11 @@ struct BookReadingStats { // Saves stats to cachePath/stats.bin. void save(const std::string& cachePath) const; + // CrumBLE 4.4 (ported from CrossInk v1.3.3): deletes cachePath/stats.bin. + // Missing files are treated as success. Used by the new "Delete Book's + // Reading Stats" action in the reader menu + file-browser context menu. + static bool remove(const std::string& cachePath); + // Formats a duration in seconds into a human-readable string. // Output examples: "< 1 min", "45 min", "2h 30 min" static void formatDuration(uint32_t seconds, char* buf, size_t len); diff --git a/src/activities/reader/DictionaryDefinitionActivity.cpp b/src/activities/reader/DictionaryDefinitionActivity.cpp index ea51fff9..d0aa8b4f 100644 --- a/src/activities/reader/DictionaryDefinitionActivity.cpp +++ b/src/activities/reader/DictionaryDefinitionActivity.cpp @@ -1,12 +1,11 @@ #include "DictionaryDefinitionActivity.h" -#include // PrewarmScope (forward-declared in GfxRenderer.h) #include #include -#include #include +#include "../../SilentRestart.h" // CrumBLE 4.4: silent-restart on second-lookup OOM #include "CrossPointSettings.h" #include "DictionarySuggestionsActivity.h" #include "components/UITheme.h" @@ -66,6 +65,64 @@ void DictionaryDefinitionActivity::performLookup() { void DictionaryDefinitionActivity::wrapText() { wrappedLines.clear(); + // CrumBLE 4.4 task #51: pre-flight the wrap. Each wrapped line is a + // std::string (~24 bytes header) and renderer.wrappedText() returns + // a vector that we then insert into wrappedLines. Both + // allocations need contiguous heap; under post-Lookup heap pressure + // the insert bad_allocs -> __terminate -> panic-reboot. Bail + // gracefully with a placeholder line instead: user sees "Low memory" + // rather than a device crash. They can press Back and re-open Lookup + // later when the heap has recovered. + // + // Heap budget reference (measured via [HEAP] checkpoints, 2026-06-12): + // Boot entry (silent restart): ~115 KB MaxAlloc -- fresh heap. + // End of setup() (pre-reader): ~86 KB MaxAlloc -- after display + + // fonts + library scan. + // Reader fully loaded (first + // page rendered): ~60 KB MaxAlloc -- after EPUB parse + // + first chapter build + first page. + // Lookup activity pushed + + // dictionary entry parsed: ~30-50 KB MaxAlloc -- where wrapText + // enters. Under 8 KB the next insert + // is the crash, so that's the gate. + // + // CrumBLE 4.4 task #4 follow-up: under 2-bit MEDIUM atlas + a few + // scroll/drawer round-trips, post-Lookup heap was measured at ~19 KB + // maxAlloc -- passes the old 8 KB gate, but per-paragraph wrappedText + // calls fragment the heap fast enough that one of the small insert() + // grows still bad_allocs. Raise to 18 KB so the "Low memory" branch + // triggers BEFORE we walk into the wrap loop on a heap that's already + // doomed. A long definition typically holds ~12-15 KB of wrappedLines + // strings plus a wrappedText scratch vector during the call -- 18 KB + // gives a ~3 KB cushion against the next-page fragmentation step. + if (ESP.getMaxAllocHeap() < 18 * 1024) { + // CrumBLE 4.4 post-bisect: when the user looks up a second word (or + // any word after the first), the dictionary cache + per-paragraph + // wrappedText allocations have fragmented heap below the safe wrap + // floor. Instead of showing the "Low memory" dead-end, silent-restart + // with OpenLookup queued -- post-boot dispatch reopens the word- + // select activity on a fresh ~115 KB heap. Skip if we're already + // post-recovery (avoid restart loop on a genuinely starved heap). + if (!isContinuingFromSilentReboot()) { + LOG_INF("DICT", + "wrapText: maxAlloc=%u too low (< 18 KB); triggering silent restart with OpenDefinition('%s')", + ESP.getMaxAllocHeap(), targetWord.c_str()); + silentRestartToReaderWithDefinition(targetWord.c_str()); + return; + } + LOG_ERR("DICT", "wrapText: maxAlloc=%u too low for safe wrap even after silent restart; showing fallback line", + ESP.getMaxAllocHeap()); + clearSilentRebootContinuationFlag(); + wrappedLines.reserve(1); + wrappedLines.push_back("Low memory -- close other activities and try again"); + lineHeight = renderer.getLineHeight(BITTER_12_FONT_ID); + linesPerPage = 1; + maxScroll = 0; + return; + } + // Heap is acceptable; clear continuation flag so future ops can restart. + clearSilentRebootContinuationFlag(); + // FIX: Pre-allocate memory to avoid heap fragmentation during line wrapping wrappedLines.reserve(50); @@ -74,6 +131,15 @@ void DictionaryDefinitionActivity::wrapText() { const int margin = 20; const int maxWidth = renderer.getScreenWidth() - (margin * 2); + // CrumBLE 4.4 task #6: wrap + render the definition body in the + // built-in UI font instead of the reader's SD font. The SD-font path + // required a PrewarmScope to populate miniData for every codepoint in + // the definition (chapter atlas doesn't cover dictionary vocabulary), + // which fragmented heap and produced visible '?' for any glyph that + // failed to prewarm under post-Lookup heap pressure. UI font ships + // with full Latin coverage, allocates zero per-render miniData, and + // is what the rest of the dictionary UI (title, hints, "Not found" + // message) was already using -- visual consistency, not a regression. std::stringstream ss(definition); std::string paragraph; while (std::getline(ss, paragraph, '\n')) { @@ -82,13 +148,13 @@ void DictionaryDefinitionActivity::wrapText() { continue; } - auto pLines = renderer.wrappedText(fontId, paragraph.c_str(), maxWidth, 1000); + auto pLines = renderer.wrappedText(BITTER_12_FONT_ID, paragraph.c_str(), maxWidth, 1000); wrappedLines.insert(wrappedLines.end(), pLines.begin(), pLines.end()); } - lineHeight = renderer.getLineHeight(fontId); + lineHeight = renderer.getLineHeight(BITTER_12_FONT_ID); - const int titleH = renderer.getLineHeight(UI_12_FONT_ID); + const int titleH = renderer.getLineHeight(BITTER_12_FONT_ID); const int startY = margin + titleH + (margin * 2); const int bottomMarginForHints = 55; @@ -145,80 +211,63 @@ void DictionaryDefinitionActivity::loop() { } void DictionaryDefinitionActivity::render(RenderLock&&) { + // CrumBLE 4.4 post-bisect: on the post-silent-restart "Looking up..." render + // (isLoading=true while performLookup's Dictionary::lookup is doing SD reads), + // skip painting entirely. The panel is still showing the user's pre-restart + // content (book page) because we restored the framebuffer at boot, and that's + // a better visual placeholder than a "Looking up..." overlay flashing across + // it. Once the lookup completes, performLookup sets isLoading=false and calls + // requestUpdate, which falls through to the normal render path below with the + // full definition. + if (isLoading && isContinuingFromSilentReboot()) { + return; + } + renderer.clearScreen(); const int margin = 20; - // CrumBLE 4.2: same SD-font prewarm gymnastics as - // DictionaryWordSelectActivity::render. The reader font (`fontId`) is - // the user's chosen reader font, which when it's an SD-card .cpfont - // has an empty miniData unless a PrewarmScope is active -- - // EpdFontFamily::getGlyph never falls back to the glyphMissHandler so - // every codepoint renders as REPLACEMENT_GLYPH otherwise. The target - // word at the top renders with UI_12 (Inter built-in) which always - // has its intervals resident, so only the definition body was - // showing question marks. - // - // The scan pass below issues drawText calls for every visible string - // with the renderer in scanning mode -- FontCacheManager::recordText - // accumulates the codepoints without rendering. After - // endScanAndPrewarm the SD-font miniData is populated and the real - // draw loop below renders correctly. Scope dtor wipes the cache when - // render() returns. - auto* fcm = renderer.getFontCacheManager(); - std::optional prewarmScope; - if (fcm) { - prewarmScope.emplace(fcm->createPrewarmScope()); - // y/x positions don't matter during the scan pass -- drawText is a - // no-op visually when scanning. Just walk the same strings the real - // draw will use. - if (isLoading) { - renderer.drawText(fontId, 0, 0, tr(STR_LOOKING_UP)); - } else if (notFound) { - const std::string notFoundMsg = std::string(tr(STR_WORD_NOT_FOUND)) + targetWord; - renderer.drawText(fontId, 0, 0, notFoundMsg.c_str()); - renderer.drawText(fontId, 0, 0, tr(STR_PRESS_CONFIRM_SUGGESTIONS)); - } else { - const int linesToScan = std::min(linesPerPage, static_cast(wrappedLines.size()) - scrollOffset); - for (int i = 0; i < linesToScan; ++i) { - renderer.drawText(fontId, 0, 0, wrappedLines[scrollOffset + i].c_str()); - } - if (scrollOffset > 0) renderer.drawText(fontId, 0, 0, "^"); - if (scrollOffset < maxScroll) renderer.drawText(fontId, 0, 0, "v"); - } - prewarmScope->endScanAndPrewarm(); - } + // CrumBLE 4.4 task #6: dictionary body renders entirely in the UI + // font now. The PrewarmScope dance that lived here -- a scan pass to + // teach FontCacheManager which codepoints to load from the SD .cpfont + // followed by endScanAndPrewarm and a dtor-time clearCache -- is + // gone: UI_12 is a built-in compressed font with full Latin coverage + // and no per-render miniData allocation. Drops ~5-8 KB of heap + // pressure off every lookup and removes the dependency on chapter + // atlas coverage that produced '?' glyphs and mashed-word spacing + // before this rewrite. fontId stays in the ctor signature so callers + // don't break, but it's no longer read. int currentY = margin; if (isLoading) { - renderer.drawText(fontId, margin, currentY, tr(STR_LOOKING_UP)); + renderer.drawText(BITTER_12_FONT_ID, margin, currentY, tr(STR_LOOKING_UP)); } else if (notFound) { std::string notFoundMsg = std::string(tr(STR_WORD_NOT_FOUND)) + targetWord; - renderer.drawText(fontId, margin, currentY, notFoundMsg.c_str()); + renderer.drawText(BITTER_12_FONT_ID, margin, currentY, notFoundMsg.c_str()); - currentY += renderer.getLineHeight(fontId) * 2; - renderer.drawText(fontId, margin, currentY, tr(STR_PRESS_CONFIRM_SUGGESTIONS)); + currentY += renderer.getLineHeight(BITTER_12_FONT_ID) * 2; + renderer.drawText(BITTER_12_FONT_ID, margin, currentY, tr(STR_PRESS_CONFIRM_SUGGESTIONS)); } else { - renderer.drawText(UI_12_FONT_ID, margin, currentY, targetWord.c_str(), EpdFontFamily::BOLD); + renderer.drawText(BITTER_12_FONT_ID, margin, currentY, targetWord.c_str(), EpdFontFamily::BOLD); - int titleWidth = renderer.getTextWidth(UI_12_FONT_ID, targetWord.c_str(), EpdFontFamily::BOLD); - int titleH = renderer.getLineHeight(UI_12_FONT_ID); + int titleWidth = renderer.getTextWidth(BITTER_12_FONT_ID, targetWord.c_str(), EpdFontFamily::BOLD); + int titleH = renderer.getLineHeight(BITTER_12_FONT_ID); renderer.fillRect(margin, currentY + titleH + 4, titleWidth, 3, true); currentY += titleH + (margin * 2); int linesToDraw = std::min(linesPerPage, static_cast(wrappedLines.size()) - scrollOffset); for (int i = 0; i < linesToDraw; ++i) { - renderer.drawText(fontId, margin, currentY, wrappedLines[scrollOffset + i].c_str()); + renderer.drawText(BITTER_12_FONT_ID, margin, currentY, wrappedLines[scrollOffset + i].c_str()); currentY += lineHeight; } if (scrollOffset > 0) { - renderer.drawText(fontId, renderer.getScreenWidth() - margin - 20, margin * 2, "^"); + renderer.drawText(BITTER_12_FONT_ID, renderer.getScreenWidth() - margin - 20, margin * 2, "^"); } if (scrollOffset < maxScroll) { - renderer.drawText(fontId, renderer.getScreenWidth() - margin - 20, renderer.getScreenHeight() - 60, "v"); + renderer.drawText(BITTER_12_FONT_ID, renderer.getScreenWidth() - margin - 20, renderer.getScreenHeight() - 60, "v"); } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ecaac1af..11941795 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -22,12 +22,15 @@ #include #include +#include "../../SilentRestart.h" // CrumBLE 4.4: silent-restart-before-BT pre-flight #include "../settings/BluetoothSettingsActivity.h" #include "../settings/KOReaderSettingsActivity.h" #include "BookSettingsDrawerActivity.h" // CrumBLE: dictionary feature (ported from SEEK reader sumegig/seek-reader). +#include "DictionaryDefinitionActivity.h" #include "DictionaryIndexBuildActivity.h" #include "DictionaryWordSelectActivity.h" +#include "fontIds.h" // BITTER_12_FONT_ID for direct-launched definitions #include "LookedUpWordsActivity.h" #include "util/Dictionary.h" #include "util/LookupHistory.h" @@ -460,7 +463,11 @@ void EpubReaderActivity::onEnter() { APP_STATE.saveToFile(); } else { FsFile f; - if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { + // CrumBLE 4.4: quiet existence check first to silence the noisy + // "[ERS] File does not exist" line when opening a never-read book. + // Missing progress.bin is the normal "fresh book" state, not an error. + const std::string progressPath = epub->getCachePath() + "/progress.bin"; + if (Storage.exists(progressPath.c_str()) && Storage.openFileForRead("ERS", progressPath, f)) { uint8_t data[6]; int dataSize = f.read(data, 6); if (dataSize == 4 || dataSize == 6) { @@ -1032,6 +1039,31 @@ void EpubReaderActivity::onExit() { // guards `if (!epub) return;`, subsuming 1.3's null-check on save.) commitReadingSession(); + // CrumBLE 4.4: defensive save-on-exit. Progress is already saved on + // every page render (line ~4131), so the typical exit has nothing new + // to write. But if the render-time save FAILED under heap fragmentation + // (e.g. mid-page interaction left MaxAlloc too low for the FsFile + // alloc), the book gets stuck on its last successfully-saved page -- + // user-fatal because only deleting/re-uploading the book clears it. + // Here at onExit the heap is usually cleaner (page DOM released, + // BT teardown queued), so one more attempt with a heap pre-flight + // catches the common case at near-zero cost. + if (epub && section && pendingSyncSaveError) { + constexpr uint32_t kExitSaveMinMaxAlloc = 4 * 1024; + const uint32_t exitMaxAlloc = ESP.getMaxAllocHeap(); + if (exitMaxAlloc >= kExitSaveMinMaxAlloc) { + if (saveProgress(currentSpineIndex, section->currentPage, section->pageCount)) { + LOG_INF("ERS", "Defensive save-on-exit recovered progress (maxAlloc=%u)", exitMaxAlloc); + pendingSyncSaveError = false; + } else { + LOG_INF("ERS", "Defensive save-on-exit still failed (maxAlloc=%u)", exitMaxAlloc); + } + } else { + LOG_INF("ERS", "Defensive save-on-exit skipped: heap too low (maxAlloc=%u < %u)", + exitMaxAlloc, kExitSaveMinMaxAlloc); + } + } + BOOKMARKS.unload(); section.reset(); @@ -1107,6 +1139,76 @@ void EpubReaderActivity::loop() { runDeferredOnEnter(); } + // CrumBLE 4.4 post-bisect: dispatch the post-boot action queued by the + // previous boot's silentRestartToReaderWithAction(). Runs once after the + // first render has landed AND deferred init has finished -- so the heap + // is in its stable post-boot shape, not mid-fast-open. + // EnableBt -> set pendingBleQuickConnect_ (existing BT-connect SM) + // OpenLookup -> re-fire the LOOKUP menu action on a fresh heap + // OpenHighlight -> re-fire ADD_HIGHLIGHT + { + static bool postBootActionDispatched = false; + if (!postBootActionDispatched && firstRenderCompleted_ && !deferredOnEnterPending_) { + postBootActionDispatched = true; + const ReaderPostBootAction action = consumeReaderPostBootAction(); + if (action == ReaderPostBootAction::EnableBt) { + LOG_INF("ERA", "post-boot dispatch: EnableBt -> pendingBleQuickConnect_ (free=%u maxAlloc=%u)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + pendingBleQuickConnect_ = true; + pendingBleQuickConnectNoImages_ = false; + pendingBleQuickConnectSettingsChanged_ = false; + pendingBleQuickConnectPromptStage_ = -1; + requestUpdate(); + } else if (action == ReaderPostBootAction::OpenLookup) { + LOG_INF("ERA", "post-boot dispatch: OpenLookup (free=%u maxAlloc=%u)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::LOOKUP); + } else if (action == ReaderPostBootAction::OpenHighlight) { + LOG_INF("ERA", "post-boot dispatch: OpenHighlight (free=%u maxAlloc=%u)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::ADD_HIGHLIGHT); + } else if (action == ReaderPostBootAction::OpenDefinition) { + const char* word = consumePendingDefinitionWord(); + if (word && word[0] != '\0' && epub) { + LOG_INF("ERA", "post-boot dispatch: OpenDefinition('%s') (free=%u maxAlloc=%u)", + word, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + // CrumBLE 4.4 post-bisect: thread the word into the LOOKUP flow + // so the word-select activity opens with cursor on the word AND + // auto-opens the definition overlay. Replaces the prior + // DictionaryDefinitionActivity push path -- now the overlay is + // the SINGLE rendering path for definitions, so post-boot dispatch + // routes through LOOKUP regardless of where the user was when + // the silent-restart fired. + pendingLookupDefinitionWord_ = word; + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::LOOKUP); + } else { + LOG_INF("ERA", "post-boot dispatch: OpenDefinition but no word queued; falling back to OpenLookup"); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::LOOKUP); + } + } else if (action == ReaderPostBootAction::OpenReadingStats) { + LOG_INF("ERA", "post-boot dispatch: OpenReadingStats (free=%u maxAlloc=%u)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::READING_STATS); + } else if (action == ReaderPostBootAction::OpenKoSync) { + LOG_INF("ERA", "post-boot dispatch: OpenKoSync (free=%u maxAlloc=%u)", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SYNC); + } else if (action == ReaderPostBootAction::OpenLookupAtWord) { + const char* word = consumePendingDefinitionWord(); + if (word && word[0] != '\0' && epub) { + LOG_INF("ERA", "post-boot dispatch: OpenLookupAtWord('%s') (free=%u maxAlloc=%u)", + word, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + pendingLookupDefinitionWord_ = word; + pendingLookupCursorOnly_ = true; + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::LOOKUP); + } else { + LOG_INF("ERA", "post-boot dispatch: OpenLookupAtWord but no word queued; falling back to OpenLookup"); + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::LOOKUP); + } + } + } + } + // A chapter layout aborted under BLE pressure and we requested a BLE disable. // Now that the main loop has actually torn the stack down (full heap), fire the // one retry build -- so it sees the freed heap instead of racing the deferred @@ -1369,6 +1471,52 @@ void EpubReaderActivity::loop() { pendingBleQuickConnectNoImages_ = false; pendingBleQuickConnectPromptStage_ = -1; if (noImages) renderer.setSuppressImages(true); + + // CrumBLE 4.4 post-bisect: BT enable pre-flight. If the heap is degraded + // (typical: user opened the book, browsed a bit, did lookup/highlight, + // accumulated fragmentation), silent-restart first so NimBLE inits into + // a clean ~115 KB heap and post-BT MaxAlloc lands ~12-15 KB instead of + // ~6 KB. Skips if this enable is already the result of a prior silent + // restart (avoid restart loop on the same boot's recovery dispatch). + // + // The continuation flag is CLEARED after this check runs, so a LATER + // BT enable in the same session (e.g. after lookup/highlight dragged + // heap down) can still trigger another silent restart. + // + // Threshold history: raised from 40 KB -> 55 KB after a field crash + // where SD-card-font mode (plus dark mode, both holding extra resident + // state) left the pre-BT MaxAlloc at ~43 KB. The pre-flight passed, + // but NimBLE init + connect + 6 HID report-char subscriptions then + // consumed ~66 KB of free heap, leaving MaxAlloc at 92 bytes and + // killing the next page deserialize. SD-card-font path has a higher + // resident baseline than built-in fonts (font registry buffers, atlas, + // per-section subset), so the safe floor for the post-NimBLE residual + // has to be correspondingly higher. 55 KB leaves ~10-15 KB MaxAlloc + // post-connect even in the SD-font case; degraded sessions below that + // just take the silent-restart path and reconnect from a fresh heap. + { + const uint32_t preBtFree = ESP.getFreeHeap(); + const uint32_t preBtMaxAlloc = ESP.getMaxAllocHeap(); + constexpr uint32_t kPreBtFreshMaxAllocThreshold = 55 * 1024; + if (isContinuingFromSilentReboot()) { + LOG_INF("ERA", + "BT enable pre-flight: skipped (post-recovery dispatch; this enable is the result of " + "a prior silent restart, free=%u maxAlloc=%u)", + preBtFree, preBtMaxAlloc); + clearSilentRebootContinuationFlag(); + } else if (preBtMaxAlloc < kPreBtFreshMaxAllocThreshold) { + LOG_INF("ERA", + "BT enable pre-flight: pre-BT heap is degraded " + "(free=%u maxAlloc=%u < %u); triggering silent restart with EnableBt to defrag heap", + preBtFree, preBtMaxAlloc, kPreBtFreshMaxAllocThreshold); + silentRestartToReaderWithAction(ReaderPostBootAction::EnableBt); + return; + } else { + LOG_INF("ERA", + "BT enable pre-flight: heap acceptable (free=%u maxAlloc=%u); proceeding", + preBtFree, preBtMaxAlloc); + } + } // Persistent "Connecting Bluetooth..." popup spanning the blocking // NimBLE init + GATT handshake (~2-3 s total -- enable() initializes // the controller and host, connectToDevice() establishes the link and @@ -1648,6 +1796,23 @@ void EpubReaderActivity::loop() { if (!result.isCancelled) { onReaderMenuConfirm(static_cast(menu.action)); } + // CrumBLE 4.4: post-menu heap pre-flight. If the menu allocations + a + // BT auto-reconnect that fires while the menu was up squeezed MaxAlloc + // below the page-deserialize floor (~8 KB), the very next render's + // TextBlock alloc fails with "maxAlloc=116 < needed=231" and the + // section-cache-clear retry path reinstalls the atlas, pushing the + // heap below 1 KB and triggering an abort(). Silent-restart preserves + // the open book + page progress via progress.bin and resumes on a + // fresh ~115 KB heap. Threshold mirrors the chapter-transition + // pre-flight that already exists for the same class of failure. + constexpr uint32_t POST_MENU_MIN_MAX_ALLOC = 8u * 1024u; + const uint32_t maxAlloc = ESP.getMaxAllocHeap(); + if (maxAlloc < POST_MENU_MIN_MAX_ALLOC) { + LOG_INF("ERA", + "Post-menu heap pre-flight: maxAlloc=%u below %u; silent-restart to reader", + maxAlloc, POST_MENU_MIN_MAX_ALLOC); + silentRestartToReader(); + } }); } @@ -1983,6 +2148,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction // bonded remote back on their next button press post-Lookup. auto& btMgr = BluetoothHIDManager::getInstance(); const bool bleWasOnForLookup = btMgr.isEnabled(); + // CrumBLE 4.4 post-bisect: pre-BT-disable heap gate. NimBLE's host-stop + // sequence (ble_hs_stop -> terminate connection -> free internal pools) + // transiently allocates ~5-10 KB during teardown. Under a fragmented + // heap where MaxAlloc is below that, the allocation lands in corrupted + // memory and heap_caps_free aborts with "free() target outside heap + // areas". Observed in the field: Lookup triggered with MaxAlloc=5364 + // -> btMgr.disable() -> ble_hs_stop_terminate_timeout -> heap canary + // burnt -> panic. Catch the case here BEFORE entering NimBLE teardown: + // silent-restart with OpenLookup so the post-boot flow runs with a + // cold ~115 KB heap and BT already off (so the disable inside the + // restarted LOOKUP path is a safe no-op). + constexpr uint32_t LOOKUP_PRE_DISABLE_MIN_MAX_ALLOC = 12000; + if (bleWasOnForLookup && ESP.getMaxAllocHeap() < LOOKUP_PRE_DISABLE_MIN_MAX_ALLOC && + !isContinuingFromSilentReboot()) { + LOG_INF("ERS", + "Lookup: pre-BT-disable maxAlloc=%u below %u; silent-restart with OpenLookup " + "to avoid NimBLE-teardown heap corruption", + ESP.getMaxAllocHeap(), LOOKUP_PRE_DISABLE_MIN_MAX_ALLOC); + silentRestartToReaderWithAction(ReaderPostBootAction::OpenLookup); + break; + } if (bleWasOnForLookup) { LOG_INF("ERS", "Lookup: disabling BT to free heap for word build (re-enabling on exit)"); btMgr.disable(); @@ -2000,12 +2186,20 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction // each of which is correct (better than a reboot). constexpr uint32_t LOOKUP_MIN_MAX_ALLOC = 32000; if (ESP.getMaxAllocHeap() < LOOKUP_MIN_MAX_ALLOC) { - LOG_INF("ERS", "Lookup pre-flight: maxAlloc=%u below %u even after BT off, attempting recovery", + // CrumBLE 4.4 post-bisect: silent-restart with OpenLookup queued + // instead of the "low memory" dead-end. Post-boot dispatch + // re-launches the activity. Skip if already post-recovery. + if (!isContinuingFromSilentReboot()) { + LOG_INF("ERS", + "Lookup pre-flight: maxAlloc=%u below %u; triggering silent restart with OpenLookup to defrag heap", + ESP.getMaxAllocHeap(), LOOKUP_MIN_MAX_ALLOC); + silentRestartToReaderWithAction(ReaderPostBootAction::OpenLookup); + break; + } + LOG_INF("ERS", "Lookup pre-flight: maxAlloc=%u below %u even after silent restart, showing alert", ESP.getMaxAllocHeap(), LOOKUP_MIN_MAX_ALLOC); + clearSilentRebootContinuationFlag(); // future ops can restart again if needed if (bleWasOnForLookup) btMgr.requestEnableLater(); - // CrumBLE 4.2: passive heap recovery. Drop reusable allocations - // and yield -- the next user tap on Lookup runs against the - // recovered heap. Alert text guides them through that retry. tryRecoverLowHeapForLookup(); strncpy(APP_STATE.pendingAlertTitle, tr(STR_LOW_MEMORY_LOOKUP_TITLE), sizeof(APP_STATE.pendingAlertTitle) - 1); @@ -2014,6 +2208,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction APP_STATE.hasPendingAlert.store(true, std::memory_order_release); break; } + // Heap is acceptable. Clear continuation flag so future ops + // (highlight, BT-reconnect) can silent-restart if their heap is + // degraded after this lookup runs. + clearSilentRebootContinuationFlag(); // Port of SEEK reader's dictionary lookup. Compute the orientation- // adjusted margins from the current page so the word-select overlay // can hit-test taps against the rendered glyphs; also peek the FIRST @@ -2096,10 +2294,22 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }; auto launchWordSelect = [this, pageShared, readerFontId, orientedMarginLeft, orientedMarginTop, cachePath, orientation, nextPageFirstWord, reEnableBleIfNeeded]() { + auto activity = std::make_unique( + renderer, mappedInput, std::move(*pageShared), readerFontId, + orientedMarginLeft, orientedMarginTop, cachePath, orientation, + nextPageFirstWord); + // CrumBLE 4.4 post-bisect: thread the word + cursor-only flag from + // the post-boot dispatch into the activity. + // OpenDefinition -> cursorOnly = false: navigate cursor + auto-open popup + // OpenLookupAtWord-> cursorOnly = true: navigate cursor only (dismiss path) + if (!pendingLookupDefinitionWord_.empty()) { + activity->setPendingDefinitionWord(std::move(pendingLookupDefinitionWord_), + /*openOverlay=*/!pendingLookupCursorOnly_); + pendingLookupDefinitionWord_.clear(); + pendingLookupCursorOnly_ = false; + } startActivityForResult( - std::make_unique(renderer, mappedInput, std::move(*pageShared), readerFontId, - orientedMarginLeft, orientedMarginTop, cachePath, orientation, - nextPageFirstWord), + std::move(activity), [this, reEnableBleIfNeeded](const ActivityResult& result) { reEnableBleIfNeeded(); requestUpdate(); @@ -2201,11 +2411,17 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } constexpr uint32_t HIGHLIGHT_MIN_MAX_ALLOC = 32000; if (ESP.getMaxAllocHeap() < HIGHLIGHT_MIN_MAX_ALLOC) { - LOG_INF("ERS", "AddHighlight pre-flight: maxAlloc=%u below %u, attempting recovery", + if (!isContinuingFromSilentReboot()) { + LOG_INF("ERS", + "AddHighlight pre-flight: maxAlloc=%u below %u; triggering silent restart with OpenHighlight", + ESP.getMaxAllocHeap(), HIGHLIGHT_MIN_MAX_ALLOC); + silentRestartToReaderWithAction(ReaderPostBootAction::OpenHighlight); + break; + } + LOG_INF("ERS", "AddHighlight pre-flight: maxAlloc=%u below %u even after silent restart, showing alert", ESP.getMaxAllocHeap(), HIGHLIGHT_MIN_MAX_ALLOC); + clearSilentRebootContinuationFlag(); if (bleWasOnForHighlight) btMgrH.requestEnableLater(); - // CrumBLE 4.2: passive heap recovery before the alert -- see - // tryRecoverLowHeapForLookup for the rationale. tryRecoverLowHeapForLookup(); strncpy(APP_STATE.pendingAlertTitle, tr(STR_LOW_MEMORY_LOOKUP_TITLE), sizeof(APP_STATE.pendingAlertTitle) - 1); @@ -2214,6 +2430,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction APP_STATE.hasPendingAlert.store(true, std::memory_order_release); break; } + // Heap acceptable; clear continuation so future ops can restart. + clearSilentRebootContinuationFlag(); // Same page/margin extraction as LOOKUP -- the activity needs the // current Page to render and hit-test taps. Behind RenderLock so @@ -2646,6 +2864,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction exitToHomeWithPopup(); return; } + case EpubReaderMenuActivity::MenuAction::DELETE_STATS: { + // CrumBLE 4.4 (ported from CrossInk v1.3.3): delete just this book's + // stats.bin. No cache clear, no exit -- the book stays open and + // reading position is preserved. + bool ok = false; + if (epub) { + ok = BookReadingStats::remove(epub->getCachePath()); + } + drawToast(renderer, ok ? tr(STR_BOOK_STATS_DELETED) : tr(STR_CACHE_DELETE_FAILED)); + delay(ok ? 1000 : 1500); + return; + } case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: { bool cacheDeleted = false; { @@ -2680,6 +2910,37 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::READING_STATS: { + // CrumBLE 4.4 post-bisect: pre-flight under BT. Reading Stats pushes + // a new activity (BookStatsActivity) that allocates for chart/text + // rendering; under BT (~58 KB held by NimBLE) the available MaxAlloc + // can be too low to safely both teardown NimBLE *and* construct the + // new activity. Mirror the LOOKUP pattern: if BT is up and MaxAlloc + // is below the pre-disable safe threshold, silent-restart with + // OpenReadingStats so the post-boot path runs on a fresh heap with + // BT already off. Field-observed crash: heap_caps_free assert + // ("free() target outside heap areas") during NimBLE teardown when + // Stats was opened with MaxAlloc ~5 KB. + auto& btMgr = BluetoothHIDManager::getInstance(); + constexpr uint32_t STATS_PRE_DISABLE_MIN_MAX_ALLOC = 12000; + if (btMgr.isEnabled() && ESP.getMaxAllocHeap() < STATS_PRE_DISABLE_MIN_MAX_ALLOC && + !isContinuingFromSilentReboot()) { + LOG_INF("ERS", + "Reading Stats: pre-BT-disable maxAlloc=%u below %u; silent-restart with OpenReadingStats", + ESP.getMaxAllocHeap(), STATS_PRE_DISABLE_MIN_MAX_ALLOC); + silentRestartToReaderWithAction(ReaderPostBootAction::OpenReadingStats); + break; + } + // Auto-disable BT for the duration of Reading Stats, mirroring the + // Lookup convention. The Stats screen is buttons-only (BT remote + // would just compete for input), and freeing ~58 KB of NimBLE state + // keeps the activity push within heap budget. requestEnableLater() + // brings BT back on the user's next button press post-Stats. + const bool bleWasOnForStats = btMgr.isEnabled(); + if (bleWasOnForStats) { + LOG_INF("ERS", "Reading Stats: disabling BT to free heap (re-enabling on exit)"); + btMgr.disable(); + } + clearSilentRebootContinuationFlag(); // Include elapsed time from the CURRENT (uncommitted) session // segment on top of what's been banked into stats. Previously // banked segments are already in `stats.totalReadingSeconds` @@ -2690,7 +2951,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction startActivityForResult( std::make_unique(renderer, mappedInput, epub->getPath(), epub->getTitle(), epub->getThumbBmpPath(), displayStats, globalStats), - [this](const ActivityResult&) { requestUpdate(); }); + [this, bleWasOnForStats](const ActivityResult&) { + if (bleWasOnForStats) { + BluetoothHIDManager::getInstance().requestEnableLater(); + } + requestUpdate(); + }); break; } case EpubReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: { @@ -2702,6 +2968,22 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { + // CrumBLE 4.4: KOReader Sync's TLS handshake needs ~55 KB of free + // heap (cert chain + mbedTLS scratch). Mid-reading the heap is + // typically 16-19 KB free / 13-19 KB maxAlloc -- nowhere near + // enough, and the user just gets "Not enough memory for sync -- + // please retry" with no path to actually recover. Pre-flight: + // if free heap is below the TLS floor AND we're not already in + // a post-restart attempt, silent-restart with OpenKoSync so the + // sync runs against the fresh ~115 KB post-boot heap (BT cold). + constexpr uint32_t KOSYNC_TLS_HEAP_FLOOR = 60u * 1024u; + if (ESP.getFreeHeap() < KOSYNC_TLS_HEAP_FLOOR && !isContinuingFromSilentReboot()) { + LOG_INF("KOSync", + "SYNC pre-flight: free=%u below %u; silent-restart with OpenKoSync", + ESP.getFreeHeap(), KOSYNC_TLS_HEAP_FLOOR); + silentRestartToReaderWithAction(ReaderPostBootAction::OpenKoSync); + break; + } const int currentPage = section ? section->currentPage : nextPageNumber; const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; std::optional paragraphIndex; @@ -2984,6 +3266,11 @@ void EpubReaderActivity::executeReaderQuickAction(CrossPointSettings::LONG_PRESS requestUpdate(); } break; + case CrossPointSettings::LONG_MENU_TOGGLE_DARK_MODE: + SETTINGS.readerDarkMode = SETTINGS.readerDarkMode ? 0 : 1; + SETTINGS.saveToFile(); + requestUpdate(); + break; case CrossPointSettings::LONG_MENU_OFF: default: break; @@ -3292,8 +3579,9 @@ void EpubReaderActivity::render(RenderLock&& lock) { // Show end of book screen if (currentSpineIndex == epub->getSpineItemsCount()) { - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), ReaderUtils::readerForegroundBlack(), + EpdFontFamily::BOLD); renderer.displayBuffer(); automaticPageTurnActive = false; showPendingSyncSaveError(); @@ -3569,10 +3857,39 @@ void EpubReaderActivity::render(RenderLock&& lock) { // the renderer's 1-bit blit path directly. The v39 subset stays // available as a fallback if the atlas install fails (e.g. // post-NimBLE heap too tight for the bitmap allocation). - const bool atlasOk = section->hasGlyphAtlas() && section->tryInstallGlyphAtlas(it->second->contentHash()); + // + // glyphAtlasEnabled toggle: when 0, atlas install is skipped + // entirely and we go straight to the v39 subset path -- used to + // A/B test whether the atlas integration is the source of the + // FT upload heap regression. + if (!SETTINGS.glyphAtlasEnabled && section->hasGlyphAtlas()) { + LOG_INF("SCT", "EGS gate: atlas available but glyphAtlasEnabled=0, falling back to v39 subset"); + } + const bool atlasOk = SETTINGS.glyphAtlasEnabled && section->hasGlyphAtlas() && + section->tryInstallGlyphAtlas(it->second->contentHash(), + /*preferLowBitDepth=*/BluetoothHIDManager::getInstance().isEnabled()); if (!atlasOk) { section->tryInstallEmbeddedGlyphSubset(it->second->contentHash()); } + // CrumBLE 4.4: one-time backward-compat write of prebake-cpfont.marker + // for books that were re-uploaded with the optimizer.js atlas-emit + // fix but missed the WASM CLI marker write (those came before the + // CLI rebuild that emits this marker). Once the marker exists, the + // badge tier upgrades to ✓IMG+CHAP+CP.FONT on the next FT listing + // and on the next device long-press. Storage.exists is cheap; + // gated on first-install success so we don't write for v40 + // sections that don't carry atlas. + if (atlasOk && epub) { + const std::string markerPath = + Epub::cachePathForFilePath(epub->getPath(), "/.crosspoint") + "/prebake-cpfont.marker"; + if (!Storage.exists(markerPath.c_str())) { + HalFile m; + if (Storage.openFileForWrite("ERA", markerPath, m)) { + m.close(); + LOG_INF("ERA", "Wrote backward-compat prebake-cpfont.marker for %s", epub->getPath().c_str()); + } + } + } } } // Atlas takes precedence when installed; the renderer slot is the @@ -3580,7 +3897,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { // code doesn't need to know which source produced the data. auto glyphFontData = [&](uint8_t style) -> const EpdFontData* { if (!section) return nullptr; - if (section->glyphAtlasInstalled()) { + // glyphAtlasEnabled toggle gates the atlas read path too -- with + // toggle OFF, the install above was skipped so glyphAtlasInstalled() + // is false anyway, but checking the flag here makes it explicit and + // catches the edge case where the atlas was already installed before + // the user flipped the toggle. + if (SETTINGS.glyphAtlasEnabled && section->glyphAtlasInstalled()) { if (const EpdFontData* atlas = section->glyphAtlasFontDataForStyle(style)) return atlas; } return section->embeddedFontDataForStyle(style); @@ -3646,11 +3968,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { } } - renderer.clearScreen(); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); if (section->pageCount == 0) { LOG_DBG("ERS", "No pages to render"); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), ReaderUtils::readerForegroundBlack(), + EpdFontFamily::BOLD); renderStatusBar(); renderer.displayBuffer(); automaticPageTurnActive = false; @@ -3660,7 +3983,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) { LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), ReaderUtils::readerForegroundBlack(), + EpdFontFamily::BOLD); renderStatusBar(); renderer.displayBuffer(); automaticPageTurnActive = false; @@ -3737,12 +4061,13 @@ void EpubReaderActivity::render(RenderLock&& lock) { } LOG_ERR("ERS", "Failed to load page from SD after %d retries", pageLoadRetryCount); - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), ReaderUtils::readerForegroundBlack(), + EpdFontFamily::BOLD); // The auto-retry already tried clearing+rebuilding this chapter's cache. If // it still won't load, the SD filesystem is likely the problem (it can't be // self-healed on-device) -- point the user at the recovery options. - renderer.drawCenteredText(UI_10_FONT_ID, 332, tr(STR_PAGE_LOAD_ERROR_HINT), true); + renderer.drawCenteredText(UI_10_FONT_ID, 332, tr(STR_PAGE_LOAD_ERROR_HINT), ReaderUtils::readerForegroundBlack()); renderStatusBar(); renderer.displayBuffer(); automaticPageTurnActive = false; @@ -3760,7 +4085,10 @@ void EpubReaderActivity::render(RenderLock&& lock) { // onGlyphMiss for everything (some Outside range drift, but readable). // CrumBLE 4.4 step 5: lazy reload prefers the v40 atlas over the v39 // subset when both are present, mirroring the section-open path. - const bool atlasNeedsReload = section && section->hasGlyphAtlas() && !section->glyphAtlasInstalled(); + // glyphAtlasEnabled toggle: when off, atlasNeedsReload stays false and + // the lazy reload reaches only the v39 subset path. + const bool atlasNeedsReload = SETTINGS.glyphAtlasEnabled && section && + section->hasGlyphAtlas() && !section->glyphAtlasInstalled(); const bool subsetNeedsReload = section && section->hasEmbeddedGlyphSubset() && !section->embeddedSubsetInstalled(); if (atlasNeedsReload || subsetNeedsReload) { @@ -3772,7 +4100,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { const uint32_t maxAllocBefore = ESP.getMaxAllocHeap(); bool atlasOk = false; if (atlasNeedsReload) { - atlasOk = section->tryInstallGlyphAtlas(it->second->contentHash()); + atlasOk = section->tryInstallGlyphAtlas(it->second->contentHash(), + /*preferLowBitDepth=*/BluetoothHIDManager::getInstance().isEnabled()); } bool subsetOk = section->embeddedSubsetInstalled(); // CrumBLE 4.4 v4.4.1: when the atlas is already providing data @@ -3930,7 +4259,8 @@ void EpubReaderActivity::renderContents(const Page& page, const int orientedMarg renderer.takeRenderStarved(); // clear stale; capture only this render's failures renderer.takeImageRepaintUnsafe(); // clear stale; capture only this render's uncached images - page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, + ReaderUtils::readerForegroundBlack()); // Note when the BLE remote came up. The connect handshake makes NimBLE grab // its ~58 KB and churn temporary buffers, which briefly spikes heap pressure @@ -4065,7 +4395,8 @@ void EpubReaderActivity::renderContents(const Page& page, const int orientedMarg // Re-render page content to restore images into the blanked area // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %) const auto tImageRestoreRender = millis(); - page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, + ReaderUtils::readerForegroundBlack()); const uint32_t imageRestoreRenderMs = millis() - tImageRestoreRender; const auto tImageFinalDisplay = millis(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); @@ -4118,10 +4449,15 @@ void EpubReaderActivity::renderContents(const Page& page, const int orientedMarg // grayscale rendering if (canApplyGrayscale) { + // CrumBLE 4.4: the LSB/MSB grayscale buffers are intentionally cleared to + // 0x00 (their "no glyph here" state), separate from dark mode. The actual + // foreground/background colour comes from the text draw paths below, so + // pass readerForegroundBlack into page.render the same as the BW path. + const bool fg = ReaderUtils::readerForegroundBlack(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); if (needsTextGrayscale) { - page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, fg); } else { page.renderImages(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); } @@ -4132,7 +4468,7 @@ void EpubReaderActivity::renderContents(const Page& page, const int orientedMarg renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); if (needsTextGrayscale) { - page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, fg); } else { page.renderImages(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); } @@ -4157,8 +4493,9 @@ void EpubReaderActivity::renderContents(const Page& page, const int orientedMarg // writing that BW framebuffer back to it. Skipping (2) was the cause // of the heavy ghosting — the panel kept the 4-level grayscale RAM // and the next refresh smeared against it. - renderer.clearScreen(); - page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + page.render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, + ReaderUtils::readerForegroundBlack()); renderStatusBar(); renderer.cleanupGrayscaleWithFrameBuffer(); } @@ -4225,7 +4562,8 @@ void EpubReaderActivity::renderStatusBar() const { const float rawProgress = (pageCount > 0) ? (static_cast(section->currentPage) / pageCount) : 0.0f; const bool bookmarked = BOOKMARKS.hasBookmarkForPage(static_cast(currentSpineIndex), rawProgress, section->pageCount > 0 ? section->pageCount : 1); - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, bookmarked); + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, bookmarked, + ReaderUtils::readerDarkModeEnabled()); } void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { @@ -4362,8 +4700,8 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf return false; } - renderer.clearScreen(); - page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop, ReaderUtils::readerForegroundBlack()); // No displayBuffer call; caller (SleepActivity) handles that after compositing the overlay. return true; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 07ae61f8..9192796b 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -325,6 +325,21 @@ class EpubReaderActivity final : public Activity { // bt.enable() + connectToDevice(). Doing the connect inline from the // drawer's lambda used to race the NimBLE handshake against a // heap-heavy section rebuild and brick the link. + // CrumBLE 4.4 post-bisect: post-silent-restart restore for the inline + // definition overlay. Set by the OpenDefinition post-boot dispatch from + // the word carried in silentRebootDefinitionWord; consumed by the LOOKUP + // case to thread the word into the freshly-built DictionaryWordSelectActivity + // via setPendingDefinitionWord. The activity then snaps the cursor to + // that word and auto-opens the popup, putting the user back exactly + // where they were before the heap-defrag restart. + std::string pendingLookupDefinitionWord_; + // CrumBLE 4.4 post-bisect: parallel to pendingLookupDefinitionWord_ but + // signals the post-boot dispatch came from the dismiss-time silent-restart + // path (OpenLookupAtWord). The launchWordSelect lambda threads this flag + // into the activity so it navigates the cursor to the word WITHOUT + // auto-opening the definition popup -- user resumes on the same word + // they were just reading, free to dismiss or pick a different word. + bool pendingLookupCursorOnly_ = false; bool pendingBleQuickConnect_ = false; bool pendingBleQuickConnectNoImages_ = false; // True when the drawer reported settingsChanged alongside the QC request. diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 98a0f75e..da034ee0 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -164,24 +164,24 @@ std::vector EpubReaderMenuActivity::buildMainM // from BT-on through BT-off, covering both connected and "scanning to // reconnect" states which are equally heap-pressured. const bool bleActive = BluetoothHIDManager::getInstance().isEnabled(); - if (!bleActive) { - items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP}); - if (hasDictionary && hasLookupHistory) { - items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKED_UP_WORDS}); - } + // CrumBLE 4.4 post-bisect: lookup + highlight are always visible. If + // BT is active when the user taps them, the lookup/highlight handler + // disables BT, then heap pre-flight triggers a silent restart with + // OpenLookup/OpenHighlight queued -- post-boot dispatch re-launches + // the activity on a fresh heap. User no longer has to manually + // disconnect BT first. + items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP}); + if (hasDictionary && hasLookupHistory) { + items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKED_UP_WORDS}); } (void)hasDictionary; - // Highlight quick action. Pending-hold state replaces Add with the - // Finish/Cancel pair so the menu doesn't dangle two ways to start. - // Hidden when BLE is active (same heap-pressure rationale as Lookup). - if (!bleActive) { - if (hasPendingHighlight) { - items.push_back({MenuAction::FINISH_HIGHLIGHT, StrId::STR_FINISH_HIGHLIGHT}); - items.push_back({MenuAction::CANCEL_HIGHLIGHT, StrId::STR_CANCEL_HIGHLIGHT}); - } else { - items.push_back({MenuAction::ADD_HIGHLIGHT, StrId::STR_ADD_HIGHLIGHT}); - } + if (hasPendingHighlight) { + items.push_back({MenuAction::FINISH_HIGHLIGHT, StrId::STR_FINISH_HIGHLIGHT}); + items.push_back({MenuAction::CANCEL_HIGHLIGHT, StrId::STR_CANCEL_HIGHLIGHT}); } else { + items.push_back({MenuAction::ADD_HIGHLIGHT, StrId::STR_ADD_HIGHLIGHT}); + } + if (bleActive) { // BLE active: drop the pending highlight if any was in flight so the // user doesn't return from a BT session with a half-started highlight // hanging around. The pendingHighlight state is fed in from the @@ -212,6 +212,7 @@ std::vector EpubReaderMenuActivity::buildMainM items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}); items.push_back( {MenuAction::TOGGLE_COMPLETED, isBookCompleted ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); + items.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); // isCurrentPageBookmarked / hasBookmarks are not used here directly -- diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index d1e42754..7d9ff9ab 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -24,6 +24,7 @@ class EpubReaderMenuActivity final : public Activity { GO_HOME, SYNC, DELETE_CACHE, + DELETE_STATS, // CrumBLE 4.4: ported from CrossInk v1.3.3 reading-stats split READING_STATS, TOGGLE_COMPLETED, READER_OPTIONS, diff --git a/src/activities/reader/GlobalReadingStats.cpp b/src/activities/reader/GlobalReadingStats.cpp index cc7b3089..49e493ed 100644 --- a/src/activities/reader/GlobalReadingStats.cpp +++ b/src/activities/reader/GlobalReadingStats.cpp @@ -119,3 +119,19 @@ void GlobalReadingStats::save() const { Storage.remove(GLOBAL_STATS_PATH); } } + +bool GlobalReadingStats::reset() { + // Remove primary + backup. Storage.remove returns false when the file + // doesn't exist, which is fine here -- "already gone" is the desired + // state. Treat both as success. + bool ok = true; + if (Storage.exists(GLOBAL_STATS_PATH) && !Storage.remove(GLOBAL_STATS_PATH)) { + LOG_ERR("GSTATS", "Could not remove global_stats.bin"); + ok = false; + } + if (Storage.exists(GLOBAL_STATS_BAK_PATH) && !Storage.remove(GLOBAL_STATS_BAK_PATH)) { + LOG_ERR("GSTATS", "Could not remove global_stats.bin.bak"); + ok = false; + } + return ok; +} diff --git a/src/activities/reader/GlobalReadingStats.h b/src/activities/reader/GlobalReadingStats.h index ec786719..f0dfb9f3 100644 --- a/src/activities/reader/GlobalReadingStats.h +++ b/src/activities/reader/GlobalReadingStats.h @@ -15,4 +15,9 @@ struct GlobalReadingStats { // Saves stats to /.crosspoint/global_stats.bin. void save() const; + + // Wipes the persisted all-time stats by removing the primary + backup + // files. Returns true if both removals (or the absences) succeeded. + // CrumBLE 4.4: ported from CrossInk v1.3.2 "Reset All-time Stats". + static bool reset(); }; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index ed558948..cb1546a7 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -4,6 +4,7 @@ #include #include +#include "../../SilentRestart.h" #include "CrossPointSettings.h" #include "Epub.h" #include "EpubReaderActivity.h" @@ -127,8 +128,16 @@ void ReaderActivity::onEnter() { // keep showing the previous page and the tap feels dropped. Draw the loading // popup right away so the user sees their open registered. The reader's first // render replaces it (with the animated "Indexing..." popup on a cache miss). - GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); - renderer.displayBuffer(); + // + // CrumBLE 4.4 post-bisect: skip the popup when continuing from silent reboot. + // The BootResume::Silent path already restored the user's pre-restart frame + // via loadSleepFrameBuffer + displayBuffer(HALF), so the panel is showing + // their book page -- a better "I'm working" indicator than overlaying a + // "Loading" popup that defaces the restored content. + if (!isContinuingFromSilentReboot()) { + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + renderer.displayBuffer(); + } sdFontSystem.ensureLoaded(renderer); diff --git a/src/activities/settings/BluetoothSettingsActivity.cpp b/src/activities/settings/BluetoothSettingsActivity.cpp index 76e1efc3..cc3b78a6 100644 --- a/src/activities/settings/BluetoothSettingsActivity.cpp +++ b/src/activities/settings/BluetoothSettingsActivity.cpp @@ -12,6 +12,31 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { +// CrumBLE 4.4: pre-flight before starting a BT scan. With NimBLE up and a +// book open in the background, free heap can be < 7 KB / MaxAlloc < 5 KB -- +// not enough for the scan callback to grow its result list + the post-scan +// picker activity to allocate per-device strings. The result was an abort() +// after "Scan complete, found N devices". Returns true (ok to scan) when +// there's enough headroom; otherwise sets `outError` and returns false so +// the caller can show the user a clear message rather than crashing. +bool checkScanHeapOrError(std::string& outError) { + // Need to cover: scan onResult callbacks (~12 * std::string copies) + + // picker activity (vector with ~80 B per entry) + render path. + constexpr uint32_t kScanMinFreeHeap = 14u * 1024u; + constexpr uint32_t kScanMinMaxAlloc = 8u * 1024u; + const uint32_t freeHeap = ESP.getFreeHeap(); + const uint32_t maxAlloc = ESP.getMaxAllocHeap(); + if (freeHeap >= kScanMinFreeHeap && maxAlloc >= kScanMinMaxAlloc) { + return true; + } + LOG_ERR("BT", "BT scan pre-flight refused: free=%u maxAlloc=%u (need %u/%u)", + freeHeap, maxAlloc, kScanMinFreeHeap, kScanMinMaxAlloc); + outError = "Memory low. Restart device, then scan before opening a book."; + return false; +} +} // namespace + void BluetoothSettingsActivity::onEnter() { Activity::onEnter(); @@ -264,11 +289,13 @@ void BluetoothSettingsActivity::handleMainMenuInput() { } else if (selectedIndex == kScanForDevicesIndex) { // Start scan and switch to device list if (btMgr->isEnabled()) { - btMgr->startScan(10000); - lastScanTime = millis(); - viewMode = ViewMode::DEVICE_LIST; - selectedIndex = 0; - lastError = ""; + if (checkScanHeapOrError(lastError)) { + btMgr->startScan(10000); + lastScanTime = millis(); + viewMode = ViewMode::DEVICE_LIST; + selectedIndex = 0; + lastError = ""; + } } else { lastError = "Enable BT first"; } @@ -504,6 +531,10 @@ void BluetoothSettingsActivity::handleDeviceListInput() { if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { // Quick rescan + if (!checkScanHeapOrError(lastError)) { + requestUpdate(); + return; + } LOG_INF("BT", "Quick rescan..."); lastError = "Scanning..."; btMgr->startScan(10000); @@ -516,6 +547,10 @@ void BluetoothSettingsActivity::handleDeviceListInput() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Check if "Refresh" is selected if (selectedIndex == static_cast(devices.size())) { + if (!checkScanHeapOrError(lastError)) { + requestUpdate(); + return; + } LOG_INF("BT", "Refreshing scan..."); lastError = "Scanning..."; btMgr->startScan(10000); diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index 5f610cc1..c15a4028 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -102,7 +102,12 @@ void ClearCacheActivity::clearCache() { file.close(); // Close before attempting to delete - if (Storage.removeDir(fullPath.c_str())) { + // CrumBLE 4.4 (ported from CrossInk v1.3.3): preserve per-book stats + // across a global Clear Reading Cache. Global stats were already safe + // (stored elsewhere); this change keeps per-book streaks/totals too. + // To delete an individual book's stats, the user picks "Delete Book's + // Reading Stats" from the reader menu or file-browser long-press menu. + if (clearBookCacheDirectoryPreservingStats(std::string(fullPath.c_str()))) { clearedCount++; } else { LOG_ERR("CLEAR_CACHE", "Failed to remove: %s", fullPath.c_str()); diff --git a/src/util/BookCacheUtils.cpp b/src/util/BookCacheUtils.cpp index a539c053..38c660d5 100644 --- a/src/util/BookCacheUtils.cpp +++ b/src/util/BookCacheUtils.cpp @@ -2,10 +2,82 @@ #include #include +#include #include #include #include +#include +#include + +namespace { + +// CrumBLE 4.4 (ported from upstream CrossInk v1.3.3 reading-stats split): +// minimum preservation infrastructure needed for "Clear Reading Cache" to +// keep per-book stats. The full upstream BookCacheUtils preserves several +// other state files across uploads/etc.; CrumBLE only needs the stats path. +struct PreservedCacheFile { + const char* name; + const char* tmpName; +}; + +constexpr PreservedCacheFile BOOK_STATS_FILES[] = { + {"stats.bin", "clear_preserve_stats.bin"}, +}; + +bool restorePreservedFiles(const std::string& cachePath, const PreservedCacheFile* files, const size_t count, + const bool* movedFiles) { + bool ok = true; + bool restoredAny = false; + for (size_t i = 0; i < count; i++) { + if (movedFiles && !movedFiles[i]) continue; + const std::string tmpPath = cachePath + "." + files[i].tmpName; + if (!Storage.exists(tmpPath.c_str())) continue; + + Storage.mkdir(cachePath.c_str()); + const std::string finalPath = cachePath + "/" + files[i].name; + if (Storage.exists(finalPath.c_str())) { + Storage.remove(finalPath.c_str()); + } + if (!Storage.rename(tmpPath.c_str(), finalPath.c_str())) { + LOG_ERR("BookCache", "Failed to restore preserved cache state: %s", finalPath.c_str()); + ok = false; + } else { + restoredAny = true; + } + } + if (restoredAny) { + LOG_DBG("BookCache", "Restored preserved user cache state: %s", cachePath.c_str()); + } + return ok; +} + +bool preserveStateFiles(const std::string& cachePath, const PreservedCacheFile* files, const size_t count, + bool* movedFiles) { + bool ok = true; + for (size_t i = 0; i < count; i++) { + if (movedFiles) movedFiles[i] = false; + const std::string sourcePath = cachePath + "/" + files[i].name; + const std::string tmpPath = cachePath + "." + files[i].tmpName; + + if (Storage.exists(tmpPath.c_str()) && !Storage.remove(tmpPath.c_str())) { + LOG_ERR("BookCache", "Failed to remove stale preserved state temp: %s", tmpPath.c_str()); + ok = false; + continue; + } + if (!Storage.exists(sourcePath.c_str())) continue; + if (!Storage.rename(sourcePath.c_str(), tmpPath.c_str())) { + LOG_ERR("BookCache", "Failed to preserve cache state: %s", sourcePath.c_str()); + ok = false; + } else if (movedFiles) { + movedFiles[i] = true; + } + } + return ok; +} + +} // namespace + bool isBookCacheDirectoryName(const char* name) { if (!name) { return false; @@ -32,3 +104,26 @@ void clearBookCache(const std::string& path) { } LOG_DBG("BookCache", "Done checking metadata cache for: %s", path.c_str()); } + +bool clearBookCacheDirectoryPreservingStats(const std::string& cachePath) { + if (cachePath.empty()) return false; + if (!Storage.exists(cachePath.c_str())) { + LOG_DBG("BookCache", "Cache does not exist, no action needed: %s", cachePath.c_str()); + return true; + } + + constexpr size_t kCount = std::size(BOOK_STATS_FILES); + bool movedFiles[kCount] = {}; + if (!preserveStateFiles(cachePath, BOOK_STATS_FILES, kCount, movedFiles)) { + restorePreservedFiles(cachePath, BOOK_STATS_FILES, kCount, movedFiles); + LOG_ERR("BookCache", "Aborted cache clear because preserved state could not be moved: %s", cachePath.c_str()); + return false; + } + + const bool clearOk = Storage.removeDir(cachePath.c_str()); + const bool restoreOk = restorePreservedFiles(cachePath, BOOK_STATS_FILES, kCount, movedFiles); + if (!clearOk) { + LOG_ERR("BookCache", "Failed to clear cache directory: %s", cachePath.c_str()); + } + return clearOk && restoreOk; +} diff --git a/src/util/BookCacheUtils.h b/src/util/BookCacheUtils.h index c10c8a22..6040ed82 100644 --- a/src/util/BookCacheUtils.h +++ b/src/util/BookCacheUtils.h @@ -6,5 +6,11 @@ // (EPUB, XTC, or TXT). Does nothing for other file types. void clearBookCache(const std::string& path); +// CrumBLE 4.4 (ported from CrossInk v1.3.3): clears a known book cache +// directory while preserving per-book reading stats (stats.bin). Used by +// "Clear Reading Cache" so per-book streaks/totals aren't lost. Note: takes +// a *cache* path (e.g. /.crosspoint/epub_), not a book file path. +bool clearBookCacheDirectoryPreservingStats(const std::string& cachePath); + // Returns true if the directory name matches a book cache entry. bool isBookCacheDirectoryName(const char* name); From fe8012c488005064744ecb520158a0795ebf9948 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:54:24 -0700 Subject: [PATCH 07/10] feat(home,bookshelf): cover gen + delete cascade + author normalize + Retry Failed Covers Home + bookshelf + file browser polish, plus a one-shot post-update sweep so cover-gen fixes actually take effect on existing libraries: - EOCD scan window progressive (1KB -> 4KB -> 16KB -> 64KB) so re-packaged EPUBs (Anna's Archive, calibre-rewritten, signed bundles) with trailing metadata past the prior 1KB tail have their cover-jpg readable. Falls back through smaller windows under heap pressure so books that worked before still work. - Cover-thumb marker auto-sweep on firmware-version change: APP_STATE gains lastCrumbleVersion; on boot mismatch CrumBLE walks /.crosspoint/ and deletes every thumb_failed_v3_*.marker so books whose cover gen failed under an earlier bug get re-attempted automatically. - Settings > System > Retry Failed Covers: manual lever for the same sweep so users can retry without waiting for the next update (e.g. after freeing heap, replacing a book file, etc). - Bookshelf delete cascade fix: BookActions::clearFileMetadata now also removes the book from CollectionsStore / LibraryIndex / SeriesIndex (previously only HomeActivity's inline delete did the full sweep, so bookshelf + file browser deletes left a coverless placeholder that re-appeared in Collections and couldn't be opened). - Author trailing-`;` normalizer promoted to RecentBooksStore.h as a shared free function; applied at the storage layer (addOrUpdateBook, updateBook, getDataFromBook) + JSON load. Existing recent.json content cleans on next load. - FileBrowserActionActivity badge layout: the prebake "IMG..." badge gets its own row below the title block when present, so long filenames on never-opened books (no author subtitle) get full line-1 width instead of being squeezed to half by the right-justified badge. - HomeActivity carousel frame count 1 -> 2 (back-and-forth nav hits resident cache instead of SD-paging), HALF_REFRESH_DEEP on the home entry transition so the polarity-drift scrub fires. - README: noted the FT badge hover tooltip + on-device long-press for prepared-layout view; added an Additional Features bullet covering Dark Reader Mode, Text Darkness, Paragraph Spacing, Retry Failed Covers. --- README.md | 3 + lib/ZipFile/ZipFile.cpp | 24 +- src/CoverThumbStatus.cpp | 62 +++++ src/CoverThumbStatus.h | 12 + src/CrossPointState.h | 6 + src/JsonSettingsIO.cpp | 9 +- src/RecentBooksStore.cpp | 35 ++- src/RecentBooksStore.h | 8 + src/activities/home/BookActions.cpp | 45 +++- .../home/FileBrowserActionActivity.cpp | 88 +++++--- .../home/FileBrowserActionActivity.h | 4 + src/activities/home/FileBrowserActivity.cpp | 12 + src/activities/home/HomeActivity.cpp | 29 +-- src/activities/home/HomeActivity.h | 11 +- .../home/RecentBooksGridActivity.cpp | 23 +- src/activities/settings/SettingsActivity.cpp | 25 ++ src/activities/settings/SettingsActivity.h | 5 + src/main.cpp | 213 +++++++++++++++++- 18 files changed, 527 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 9b4f64af..04823999 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Books normally index their chapter structure on first open and re-index when you The previous Bluetooth-image-cache (`.pxc`) and chapter-text flattening features are still included as part of the optimizer's pre-cache pipeline. +**Hover any pre-baked book's badge in the File Manager** to see a tooltip with the baked-in layout (font, font size, orientation, line spacing, margin). Long-press the badge on-device for the same view. The pre-cache toggle is now named **Pre-Bake (Image, Chapter, Custom Font)** with a bullet-list description so it's clearer what gets baked. + ### Bluetooth remote page-turner Pairing is done from WITHIN A BOOK ONLY! Click on the "Confirm" button while inside a book to open the reader menu. Navigate to Bluetooth and follow the instructions there to pair a BT HID remote (e.g. an [IINE GameBrick](https://www.amazon.com/dp/B0CK4DNQM4) or Free2) and use it as a wireless page-turner. BLE auto-disables when you exit the book to keep heap pressure off the parser, so you will need to reconnect again when you enter a new book. @@ -143,6 +145,7 @@ Back from any nested submenu pops one level up; back from the root exits to Home - **Auto-retry on chapter-layout abort** — if the parser trips the low-heap floor with BLE consuming its ~58 KB share, CrumBLE silently drops BLE, retries the layout with the recovered headroom, and lets the existing auto-reconnect logic re-pair on your next remote press. - **Glyph buffer pre-grown at every BT-enable site** so the font scratch's high-water mark is allocated BEFORE NimBLE eats heap, preventing the mid-page-turn allocation failures that used to drop the BT link on text-heavy chapters. - **Large-library + home stability** (v3.0.x) — streaming library index that survives big libraries, crash-proofed series detection, a Lyra Carousel heap-race crash fix, cover-thumbnail revalidation so a single book can't get stuck on a placeholder, and transparent-PNG sleep screens that reliably show the clean last book page. +- **Additional features** — Dark Reader Mode, Text Darkness, Paragraph Spacing, Retry Failed Covers For the full changelog, see [CHANGELOG.md](./CHANGELOG.md). diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index c314c0aa..303bdd79 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -291,10 +291,26 @@ bool ZipFile::loadZipDetails() { return false; // Minimum EOCD size is 22 bytes } - // We scan the last 1KB (or the whole file if smaller) for the EOCD signature - // 0x06054b50 is stored as 0x50, 0x4b, 0x05, 0x06 in little-endian - const int scanRange = fileSize > 1024 ? 1024 : fileSize; - const auto buffer = static_cast(malloc(scanRange)); + // We scan the tail of the file for the EOCD signature 0x06054b50 (stored + // as 0x50, 0x4b, 0x05, 0x06 in little-endian). + // + // CrumBLE 4.4: scan window is progressive. The ZIP spec allows the EOCD + // comment field to be up to 65535 bytes; re-packaged EPUBs (Anna's + // Archive, calibre-modified, signed bundles) sometimes push the EOCD + // signature tens of KB back from EOF. Iron Gold from Anna's Archive + // wasn't found with a 4 KB scan; needs much more. Try 64 KB (covers + // the full ZIP spec) first; if alloc fails under heap pressure, step + // down through 16 KB / 4 KB / 1 KB so every heap state still gets the + // best window we can afford. Last fallback (1 KB) matches the + // pre-CrumBLE-4.4 behaviour -- books that worked then still work now. + static constexpr int kScanRangeTiers[] = {65536, 16384, 4096, 1024}; + uint8_t* buffer = nullptr; + int scanRange = 0; + for (int tier : kScanRangeTiers) { + scanRange = fileSize > tier ? tier : static_cast(fileSize); + buffer = static_cast(malloc(scanRange)); + if (buffer) break; + } if (!buffer) { LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer"); return false; diff --git a/src/CoverThumbStatus.cpp b/src/CoverThumbStatus.cpp index bd7c2fb4..d832e808 100644 --- a/src/CoverThumbStatus.cpp +++ b/src/CoverThumbStatus.cpp @@ -6,6 +6,8 @@ #include #include +#include + namespace { // Mirrors the Epub/Xtc on-disk layout. The cache dir for any book the @@ -77,4 +79,64 @@ void clearFailed(const std::string& bookPath, int width, int height) { Storage.remove(marker.c_str()); } +int sweepAllMarkers() { + auto root = Storage.open(kCacheDir); + if (!root || !root.isDirectory()) { + if (root) root.close(); + LOG_INF("CTS", "Sweep: no /.crosspoint dir (yet); skipping"); + return 0; + } + + int removed = 0; + char nameBuf[128]; + // Iterate per-book cache subdirs (epub_* / xtc_*). Each subdir may contain + // one or more thumb_failed_v3_x.marker files -- one per size that + // failed (Carousel @ 296x468, Collections @ 130x190, etc.). + for (auto sub = root.openNextFile(); sub; sub = root.openNextFile()) { + sub.getName(nameBuf, sizeof(nameBuf)); + if (!sub.isDirectory()) { + sub.close(); + continue; + } + const std::string subPath = std::string(kCacheDir) + "/" + nameBuf; + sub.close(); + + auto bookDir = Storage.open(subPath.c_str()); + if (!bookDir || !bookDir.isDirectory()) { + if (bookDir) bookDir.close(); + continue; + } + char fileNameBuf[128]; + std::vector toRemove; + for (auto f = bookDir.openNextFile(); f; f = bookDir.openNextFile()) { + f.getName(fileNameBuf, sizeof(fileNameBuf)); + // Match thumb_failed_v3_*.marker. String::starts_with isn't available; + // do a manual prefix + suffix check. The full prefix is "thumb_failed_v3_" + // (16 chars including underscore) and suffix is ".marker" (7 chars). + const std::string filename = fileNameBuf; + const std::string prefix = "thumb_failed_v3_"; + const std::string suffix = ".marker"; + const bool matches = filename.size() > prefix.size() + suffix.size() && + filename.compare(0, prefix.size(), prefix) == 0 && + filename.compare(filename.size() - suffix.size(), suffix.size(), suffix) == 0; + f.close(); + if (matches) { + toRemove.push_back(subPath + "/" + filename); + } + } + bookDir.close(); + + for (const auto& path : toRemove) { + if (Storage.remove(path.c_str())) { + ++removed; + } else { + LOG_ERR("CTS", "Sweep: failed to remove %s", path.c_str()); + } + } + } + root.close(); + LOG_INF("CTS", "Sweep: removed %d thumb-failed marker(s)", removed); + return removed; +} + } // namespace CoverThumbStatus diff --git a/src/CoverThumbStatus.h b/src/CoverThumbStatus.h index 8df59b90..1074707b 100644 --- a/src/CoverThumbStatus.h +++ b/src/CoverThumbStatus.h @@ -42,4 +42,16 @@ void markFailed(const std::string& bookPath, int width, int height); // at; pass the same dimensions that were used in the gen attempt. void clearFailed(const std::string& bookPath, int width, int height); +// CrumBLE 4.4: wipe ALL thumb_failed_v3_*.marker files across every +// per-book cache dir under /.crosspoint/. Used by: +// 1. Boot-time auto-sweep after a firmware-version change so users +// whose covers broke on a fixed bug (e.g. the EOCD scan window +// bump) get their covers back without manual intervention. +// 2. Settings > System > Retry Failed Cover Thumbnails -- manual +// lever for users who want to re-attempt without waiting for the +// next update (e.g. they freed heap, replaced a book file, etc.). +// Returns the count of markers removed. Walks the /.crosspoint/ tree +// once; cost scales with number of cache subdirs (~10-200 typical). +int sweepAllMarkers(); + } // namespace CoverThumbStatus diff --git a/src/CrossPointState.h b/src/CrossPointState.h index 57fe3407..a59ab02c 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -18,6 +18,12 @@ class CrossPointState { uint8_t readerActivityLoadCount = 0; bool lastSleepFromReader = false; bool showBootScreen = true; + // CrumBLE 4.4: last firmware version that ran on this device. Compared + // against CRUMBLE_VERSION at boot so we can run one-shot post-update + // tasks (currently: sweep thumb_failed_v3_*.marker files so users whose + // cover gen failed under an earlier-firmware bug -- e.g. the EOCD scan + // window bump -- get their covers back automatically). + std::string lastCrumbleVersion; // Returns true if idx was shown within the last checkCount picks. // Walks backwards from the most recently written slot. diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index f6907bfa..9b789f36 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -102,6 +102,7 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { doc["pendingBookmarkSpine"] = s.pendingBookmarkSpine; doc["pendingBookmarkProgress"] = s.pendingBookmarkProgress; doc["showBootScreen"] = s.showBootScreen; + doc["lastCrumbleVersion"] = s.lastCrumbleVersion; String json; serializeJson(doc, json); @@ -140,6 +141,7 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.pendingBookmarkSpine = doc["pendingBookmarkSpine"] | static_cast(UINT16_MAX); s.pendingBookmarkProgress = doc["pendingBookmarkProgress"] | static_cast(-1.0f); s.showBootScreen = doc["showBootScreen"] | true; + s.lastCrumbleVersion = doc["lastCrumbleVersion"] | std::string(""); return true; } @@ -466,7 +468,12 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) RecentBook book; book.path = obj["path"] | std::string(""); book.title = obj["title"] | std::string(""); - book.author = obj["author"] | std::string(""); + // CrumBLE 4.4: clean previously-stored authors at load time too. + // Pre-4.4 saves wrote whatever Epub::getAuthor returned, often with a + // trailing ";" from OPF separator artifacts. Normalizing on load + // means existing recent.json content displays cleanly without + // needing a forced rewrite. + book.author = normalizeAuthorMeta(obj["author"] | std::string("")); book.coverBmpPath = obj["coverBmpPath"] | std::string(""); store.recentBooks.push_back(book); } diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 9d53f961..01096f26 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -24,13 +24,38 @@ constexpr int MAX_RECENT_BOOKS = 18; RecentBooksStore RecentBooksStore::instance; +std::string normalizeAuthorMeta(std::string s) { + // EPUBs commonly leave dangling ";" or ", ;" in the author field (an + // OPF separator with no value after it). Strip both ends of any + // whitespace + separator runs so consumers can treat a "Foo Bar;" the + // same as "Foo Bar" and an empty-after-strip result the same as a real + // empty author. + while (!s.empty()) { + const char c = s.back(); + if (c == ' ' || c == '\t' || c == ';' || c == ',' || c == '\r' || c == '\n') { + s.pop_back(); + } else { + break; + } + } + size_t i = 0; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == ';' || s[i] == '\r' || s[i] == '\n')) ++i; + if (i > 0) s.erase(0, i); + return s; +} + void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author, const std::string& coverBmpPath) { addOrUpdateBook(path, title, author, coverBmpPath); } -void RecentBooksStore::addOrUpdateBook(const std::string& path, const std::string& title, const std::string& author, +void RecentBooksStore::addOrUpdateBook(const std::string& path, const std::string& title, const std::string& rawAuthor, const std::string& coverBmpPath) { + // CrumBLE 4.4: normalize the author once at the storage boundary so + // every downstream display site (Lyra carousel, Minimal theme, file + // browser long-press subtitle, etc.) reads a clean string without + // each having to re-strip the EPUB-leftover ";". + const std::string author = normalizeAuthorMeta(rawAuthor); // Drop stale entries first so a new add can't evict a valid book in their stead. pruneMissing(); @@ -55,7 +80,7 @@ void RecentBooksStore::addOrUpdateBook(const std::string& path, const std::strin saveToFile(); } -bool RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author, +bool RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& rawAuthor, const std::string& coverBmpPath) { auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); @@ -64,7 +89,7 @@ bool RecentBooksStore::updateBook(const std::string& path, const std::string& ti } RecentBook& book = *it; book.title = title; - book.author = author; + book.author = normalizeAuthorMeta(rawAuthor); book.coverBmpPath = coverBmpPath; saveToFile(); return true; @@ -136,12 +161,12 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const { if (FsHelpers::hasEpubExtension(lastBookFileName)) { Epub epub(path, "/.crosspoint"); epub.load(false, true); - return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()}; + return RecentBook{path, epub.getTitle(), normalizeAuthorMeta(epub.getAuthor()), epub.getThumbBmpPath()}; } else if (FsHelpers::hasXtcExtension(lastBookFileName)) { // Handle XTC file Xtc xtc(path, "/.crosspoint"); if (xtc.load()) { - return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()}; + return RecentBook{path, xtc.getTitle(), normalizeAuthorMeta(xtc.getAuthor()), xtc.getThumbBmpPath()}; } } else if (FsHelpers::hasTxtExtension(lastBookFileName) || FsHelpers::hasMarkdownExtension(lastBookFileName)) { return RecentBook{path, lastBookFileName, "", ""}; diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index ae9da4ad..7c37849c 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -12,6 +12,14 @@ struct RecentBook { bool operator==(const RecentBook& other) const { return path == other.path; } }; +// CrumBLE 4.4: shared author-metadata normalizer. Strips leading/trailing +// whitespace + separator characters (";", ",") that EPUB OPF often leaves +// behind when an author field has no value or extra delimiters between +// multiple authors. Idempotent on clean input. Applied at the storage +// layer (RecentBooksStore + JSON load) so every display site reads clean +// data without needing to know about EPUB metadata quirks. +std::string normalizeAuthorMeta(std::string s); + class RecentBooksStore; namespace JsonSettingsIO { bool loadRecentBooks(RecentBooksStore& store, const char* json); diff --git a/src/activities/home/BookActions.cpp b/src/activities/home/BookActions.cpp index 482d72bc..9efaee1b 100644 --- a/src/activities/home/BookActions.cpp +++ b/src/activities/home/BookActions.cpp @@ -14,7 +14,9 @@ #include "CollectionsStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "LibraryIndex.h" #include "RecentBooksStore.h" +#include "SeriesIndex.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/GlobalReadingStats.h" #include "components/UITheme.h" @@ -87,6 +89,13 @@ std::vector buildBookActionItems(const std: if (hasClearableBookCache(fullPath)) { items.push_back({FileBrowserAction::DeleteCache, StrId::STR_DELETE_CACHE}); } + // 5b. CrumBLE 4.4 (ported from CrossInk v1.3.3): delete just this book's + // reading stats (stats.bin). Shown only for EPUBs since CrumBLE's stats + // pipeline records per-EPUB. Placed adjacent to Delete Cache so the two + // destructive-but-bounded actions cluster together above plain Delete. + if (FsHelpers::hasEpubExtension(fullPath)) { + items.push_back({FileBrowserAction::DeleteStats, StrId::STR_DELETE_BOOK_STATS}); + } // 6. Delete file -- always last as the most destructive. items.push_back({FileBrowserAction::Delete, StrId::STR_DELETE}); @@ -100,12 +109,24 @@ bool hasClearableBookCache(const std::string& path) { std::string optimizedHeaderLabel(const std::string& fullPath) { // Only EPUB carries a prebake. Skip the SD lookup for non-EPUB paths. if (!FsHelpers::hasEpubExtension(fullPath)) return ""; - // Marker file -- written by the 4.2+ WASM optimizer alongside - // prebake-manifest.json. Storage.exists is a fast inode lookup - // (no read), cheap enough to call from a long-press path that runs - // once per book open. - const std::string markerPath = Epub::cachePathForFilePath(fullPath, "/.crosspoint") + "/prebake-v2.marker"; - return Storage.exists(markerPath.c_str()) ? "Optimized" : ""; + // CrumBLE 4.4: three-tier badge (matches the FT page wording exactly). + // prebake-v2.marker only -> "✓IMG" + // + prebake-chap.marker (or sections-prebake/) -> "✓IMG+CHAP" + // + prebake-cpfont.marker -> "✓IMG+CHAP+CP.FONT" + // Each Storage.exists is a fast inode lookup; the four lookups still + // fit comfortably within the long-press path's budget. + const std::string cacheDir = Epub::cachePathForFilePath(fullPath, "/.crosspoint"); + if (!Storage.exists((cacheDir + "/prebake-v2.marker").c_str())) return ""; + const bool hasChap = Storage.exists((cacheDir + "/prebake-chap.marker").c_str()) || + Storage.exists((cacheDir + "/sections-prebake").c_str()); + const bool hasCpFont = Storage.exists((cacheDir + "/prebake-cpfont.marker").c_str()); + // No leading checkmark on device-side labels -- the long-press header + // renders a lightning glyph (drawBolt) next to the label, which carries + // the "this is the optimization indicator" signal. The FT page uses + // an emoji ⚡ in the text instead since it renders cleanly in browsers. + if (hasChap && hasCpFont) return "IMG+CHAP+CP.FONT"; + if (hasChap) return "IMG+CHAP"; + return "IMG"; } BookHeaderText resolveBookHeaderText(const std::string& fullPath) { @@ -162,14 +183,26 @@ BookHeaderText resolveBookHeaderText(const std::string& fullPath) { } void clearFileMetadata(const std::string& fullPath) { + // CrumBLE 4.4: clear EVERY index/store that holds a reference to this book + // path, not just bookmarks + cache. Previously only HomeActivity's delete + // path did the full sweep inline -- the bookshelf / file browser deletes + // called this helper which left stale entries in Collections, LibraryIndex, + // and SeriesIndex. Symptom: deleting a book from inside the bookshelf left + // a coverless placeholder that re-appeared in Collections and couldn't be + // opened (file gone but RECENT_BOOKS/index entries lingered). Doing the + // full cleanup in one place means all four delete sites stay in sync. if (FsHelpers::hasEpubExtension(fullPath)) { Epub(fullPath, "/.crosspoint").clearCache(); BookmarkStore::deleteForFilePath(fullPath, "epub"); } else if (FsHelpers::hasXtcExtension(fullPath)) { + Xtc(fullPath, "/.crosspoint").clearCache(); BookmarkStore::deleteForFilePath(fullPath, "xtc"); } else if (FsHelpers::hasTxtExtension(fullPath) || FsHelpers::hasMarkdownExtension(fullPath)) { BookmarkStore::deleteForFilePath(fullPath, "txt"); } + CollectionsStore::getInstance().removeBookFromAllCollections(fullPath); + LibraryIndex::getInstance().forgetPath(fullPath); + SeriesIndex::getInstance().forgetPath(fullPath); LOG_DBG("BookActions", "Cleared metadata for: %s", fullPath.c_str()); } diff --git a/src/activities/home/FileBrowserActionActivity.cpp b/src/activities/home/FileBrowserActionActivity.cpp index 736613e7..90e2a7c6 100644 --- a/src/activities/home/FileBrowserActionActivity.cpp +++ b/src/activities/home/FileBrowserActionActivity.cpp @@ -29,12 +29,14 @@ void FileBrowserActionActivity::onEnter() { } void FileBrowserActionActivity::loop() { - // CrumBLE 4.2: the Optimized header is selectable when this activity - // was opened with headerRightLabel == "Optimized". When that's true, - // selectedIndex == -1 means the header is focused. Other right-label - // text (e.g. the shelf-header menu's sort indicator) stays passive -- - // the gate keeps the new nav path from affecting unrelated menus. - const bool headerSelectable = (headerRightLabel == "Optimized"); + // CrumBLE 4.4: the prebake badge header is selectable when this activity + // was opened with one of the v4.4 tier labels (IMG, IMG+CHAP, + // IMG+CHAP+CP.FONT). selectedIndex == -1 then means the header is focused + // -- pressing Confirm fires ViewOptimizedDetails. We match on the + // leading "IMG" so all tiers gate identically. Other right-label text + // (sort indicators, etc.) doesn't start with "IMG". + const bool headerSelectable = !headerRightLabel.empty() && + headerRightLabel.compare(0, 3, "IMG") == 0; if (ignoreConfirmRelease) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { @@ -145,8 +147,22 @@ void FileBrowserActionActivity::render(RenderLock&&) { const bool tallHeader = metrics.headerHeight > 60; const int titleY = metrics.topPadding + (tallHeader ? metrics.batteryBarHeight + 3 : kCompactTitleY); const int titleBottomPadding = tallHeader ? kTallHeaderTitleBottomPadding : kCompactHeaderTitleBottomPadding; + // CrumBLE 4.4: when the prebake-status badge ("IMG"-prefixed) is present, + // give it its OWN row below the title block instead of sharing line 1 + // with the title (no-subtitle case) or line 2 with the subtitle. The + // header's bottom edge -- the visible divider before the action list -- + // moves down accordingly. Without this, long filenames on a never-opened + // book (where title=filename and there's no author subtitle) get squeezed + // to roughly half-width because the badge reserves the other half. With + // it, the title gets full width on every line and the badge sits flush + // right on its own row above the divider. Other right-labels (e.g. the + // shelf-header sort-mode label) stay inline -- only the IMG-prefixed + // prebake badge moves down. + const bool badgeOnOwnRow = !headerRightLabel.empty() && headerRightLabel.compare(0, 3, "IMG") == 0; + const int badgeRowHeight = badgeOnOwnRow ? (subtitleLineHeight + kTitleLineGap) : 0; const int actionHeaderHeight = - std::max(metrics.headerHeight, titleY - metrics.topPadding + titleBlockHeight + titleBottomPadding); + std::max(metrics.headerHeight, + titleY - metrics.topPadding + titleBlockHeight + badgeRowHeight + titleBottomPadding); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, actionHeaderHeight}, ""); for (int i = 0; i < static_cast(titleLines.size()); ++i) { @@ -179,34 +195,46 @@ void FileBrowserActionActivity::render(RenderLock&&) { // line 2. Otherwise it rides line 1 as before. if (!headerRightLabel.empty()) { constexpr int kRightLabelFontId = UI_10_FONT_ID; - const bool drawBolt = (headerRightLabel == "Optimized"); + // Match all three v4.4 tiers (IMG, IMG+CHAP, IMG+CHAP+CP.FONT) by the + // "IMG" prefix -- same gate as headerSelectable above. The bolt glyph + // is drawn next to the label as the "optimized" indicator, since the + // device's UI font doesn't render U+2713 ✓. + const bool drawBolt = !headerRightLabel.empty() && + headerRightLabel.compare(0, 3, "IMG") == 0; // CrumBLE 4.2: when the user navigates UP from item 0 onto the - // header, paint the label + bolt inside a filled black box with - // inverted (white) text/glyph so it reads as "selected" the same way - // the regular menu rows do. headerFocused is only true when the - // header is interactive (== "Optimized"), so other right-labels stay - // passive. + // header, paint the label inside a filled black box with inverted + // (white) text so it reads as "selected" like the regular rows. const bool headerFocused = drawBolt && (selectedIndex == -1); constexpr int kBoltWidth = 8; constexpr int kBoltHeight = 13; constexpr int kBoltGap = 3; const int reserveForBolt = drawBolt ? (kBoltWidth + kBoltGap) : 0; - // Reserve a wider chunk on line 2 (where the subtitle author text - // often runs long); narrower on line 1 (where the title text is what - // matters and the right label is decorative). - // - // On line 2 we can also push the right edge ALL the way to the side - // padding (not just titleMaxWidth's right edge): the battery readout - // only occupies line 1, so kBatteryTextReserveWidth is irrelevant on - // the subtitle row. This gives ~90 extra px on the right and lets the - // label sit flush against the screen margin instead of floating at - // 2/3 of the line. - const int rightAnchorX = hasSubtitle ? (pageWidth - metrics.contentSidePadding) : (titleX + titleMaxWidth); - const int textBudget = std::max(0, (hasSubtitle ? (titleMaxWidth * 2 / 3) : (titleMaxWidth / 2)) - reserveForBolt); + // CrumBLE 4.4: when the prebake badge gets its own row (badgeOnOwnRow + // above), it has the FULL header width to itself -- no battery icon, + // no title text competing on the same line. Anchor flush right and + // budget the full width (minus paddings + bolt reserve). Other right- + // labels (sort mode, etc.) stay inline with the legacy budget split. + const int rightAnchorX = + (badgeOnOwnRow || hasSubtitle) ? (pageWidth - metrics.contentSidePadding) : (titleX + titleMaxWidth); + const int legacyBudget = (hasSubtitle ? (titleMaxWidth * 2 / 3) : (titleMaxWidth / 2)) - reserveForBolt; + const int ownRowBudget = pageWidth - 2 * metrics.contentSidePadding - reserveForBolt; + const int textBudget = std::max(0, badgeOnOwnRow ? ownRowBudget : legacyBudget); const std::string rightLabel = renderer.truncatedText(kRightLabelFontId, headerRightLabel.c_str(), textBudget); const int rw = renderer.getTextWidth(kRightLabelFontId, rightLabel.c_str(), EpdFontFamily::REGULAR); - const int rx = rightAnchorX - rw - reserveForBolt; - const int labelY = hasSubtitle ? subtitleY : titleY; + // CrumBLE 4.4: bolt moved to the LEFT of the text. The block-as-a-whole + // (bolt + gap + text) right-aligns to rightAnchorX; the bolt sits + // immediately to the left of the text. Order on screen left-to-right: + // [bolt][gap][text]. Matches the FT page's "⚡IMG+CHAP+CP.FONT" layout. + const int rx = rightAnchorX - rw; + // labelY: own row sits below the existing title block (one + // subtitleLineHeight + gap further down). Otherwise legacy behavior: + // line 2 if there's a subtitle, line 1 otherwise. + const int titleBlockEndY = + titleY + static_cast(titleLines.size()) * titleLineHeight + + std::max(0, static_cast(titleLines.size()) - 1) * kTitleLineGap + + (hasSubtitle ? (kTitleLineGap + subtitleLineHeight) : 0); + const int labelY = badgeOnOwnRow ? (titleBlockEndY + kTitleLineGap) + : (hasSubtitle ? subtitleY : titleY); // Draw the selection box BEFORE the text so the text/glyph render on // top of the filled background. Padding picked to look balanced with @@ -214,7 +242,8 @@ void FileBrowserActionActivity::render(RenderLock&&) { if (headerFocused) { constexpr int kPadX = 3; constexpr int kPadY = 2; - const int boxX = rx - kPadX; + // Box covers bolt + gap + text. Bolt sits at (rx - kBoltGap - kBoltWidth). + const int boxX = rx - reserveForBolt - kPadX; const int boxY = labelY - kPadY; const int boxW = rw + reserveForBolt + 2 * kPadX; const int boxH = subtitleLineHeight + 2 * kPadY; @@ -233,7 +262,8 @@ void FileBrowserActionActivity::render(RenderLock&&) { // form the canonical Z kink at the middle -- and bottom apex on // lower-left at (bx+1,13). Sharp points on both ends; no flat // edges meeting the bounding box. - const int bx = rx + rw + kBoltGap; + // bx is the LEFT edge of the bolt glyph; sits to the left of text. + const int bx = rx - kBoltGap - kBoltWidth; // Center the bolt vertically against the label's line height. The // ratio of (lineHeight - boltHeight) / 2 nudges by half the slack // so the glyph reads as a glyph rather than floating to the cap diff --git a/src/activities/home/FileBrowserActionActivity.h b/src/activities/home/FileBrowserActionActivity.h index 3e17a173..4f389c8a 100644 --- a/src/activities/home/FileBrowserActionActivity.h +++ b/src/activities/home/FileBrowserActionActivity.h @@ -110,6 +110,10 @@ enum class FileBrowserAction : int { // verify what their saved layout actually looks like without restoring // it. ViewOptimizedDetails = 25, + // CrumBLE 4.4 (ported from CrossInk v1.3.3): deletes just this book's + // stats.bin via BookReadingStats::remove(). Surfaced from the file + // browser's long-press menu when an EPUB is selected. + DeleteStats = 26, }; class FileBrowserActionActivity final : public Activity { diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 717690c7..2423e07c 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -2,6 +2,8 @@ #include #include + +#include "activities/reader/BookReadingStats.h" // CrumBLE 4.4: DeleteStats action #include #include #include @@ -326,6 +328,16 @@ void FileBrowserActivity::showFileActionMenu(const std::string& entry, bool igno } requestUpdate(); return; + case FileBrowserAction::DeleteStats: { + // CrumBLE 4.4 (ported from CrossInk v1.3.3): delete just this + // book's stats.bin, leaving its cache + reading position intact. + const std::string cachePath = Epub(fullPath, "/.crosspoint").getCachePath(); + const bool ok = BookReadingStats::remove(cachePath); + BookActions::drawToast(renderer, ok ? tr(STR_BOOK_STATS_DELETED) : tr(STR_CACHE_DELETE_FAILED)); + delay(ok ? 1000 : 1500); + requestUpdate(); + return; + } case FileBrowserAction::ToggleCompleted: if (BookActions::toggleEpubCompleted(fullPath, getFileName(entry), completedFeedbackIsFinished)) { pendingCompletedFeedback = true; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index d7b19dcc..0be878e9 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -3349,31 +3349,18 @@ void HomeActivity::updateFocusedBookMeta(const std::string& path) { const std::string fname = (slash != std::string::npos) ? path.substr(slash + 1) : path; // Read the cached metadata only (buildIfMissing=false): cheap, and leaves the // title blank for un-indexed books so the caller falls back to the filename. - // Strip trailing ";"/whitespace from author -- some EPUBs leave a - // separator without a value, which without this trim renders as a - // dangling ";" on the carousel/shelf. Local helper inline to avoid - // pulling RecentBooksGridActivity's namespace. - const auto trimAuthor = [](std::string s) -> std::string { - while (!s.empty()) { - const char c = s.back(); - if (c == ' ' || c == '\t' || c == ';' || c == ',' || c == '\r' || c == '\n') s.pop_back(); - else break; - } - size_t i = 0; - while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == ';' || s[i] == '\r' || s[i] == '\n')) ++i; - if (i > 0) s.erase(0, i); - return s; - }; + // CrumBLE 4.4: use the shared normalizeAuthorMeta (RecentBooksStore.h) so + // every author-display path goes through the same trim rules. if (FsHelpers::hasEpubExtension(fname)) { Epub epub(path, "/.crosspoint"); epub.load(/*buildIfMissing=*/false, /*skipLoadingCss=*/true); focusedMetaTitle = epub.getTitle(); - focusedMetaAuthor = trimAuthor(epub.getAuthor()); + focusedMetaAuthor = normalizeAuthorMeta(epub.getAuthor()); } else if (FsHelpers::hasXtcExtension(fname)) { Xtc xtc(path, "/.crosspoint"); if (xtc.load()) { focusedMetaTitle = xtc.getTitle(); - focusedMetaAuthor = trimAuthor(xtc.getAuthor()); + focusedMetaAuthor = normalizeAuthorMeta(xtc.getAuthor()); } } // .txt / .md have no embedded metadata — leave title empty (filename fallback). @@ -3384,7 +3371,13 @@ void HomeActivity::presentHomeBuffer() { pendingFullRefresh = false; // One full clear on entry wipes ghosting bled through from the previous // screen (the reader page, a low-memory alert, etc.). - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + // CrumBLE 4.4: use HALF_REFRESH_DEEP on this transition specifically. + // On X3 it adds an extra resync cycle (~770ms) to scrub polarity drift + // accumulated during long dark-mode reader sessions; without it the + // book->home transition occasionally flashes inverted. Other HALF + // callers (sleep cycle, sleep entry/exit) stay on the cheaper single + // resync. No-op vs HALF on X4. + renderer.displayBuffer(HalDisplay::HALF_REFRESH_DEEP); } else { renderer.displayBuffer(); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index c7bdc09d..1859d047 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -18,9 +18,14 @@ struct Rect; class HomeActivity final : public Activity { public: - // Keep one rendered carousel frame in RAM. Additional frames remain available - // through the SD snapshot cache and are paged in on demand. - static constexpr int kCarouselFrameCount = 1; + // CrumBLE 4.4: bumped 1->2. With 1 frame, every Left/Right press was a + // guaranteed RAM miss → SD seek + ~48 KB read per move. 2 frames give + // back-and-forth a hit (LRU evicts the older direction) while still + // costing only one extra framebuffer (~48 KB heap) vs the original 3. + // Continuous L/L/L travel still pays SD on every move, but the user- + // perceived "feels slow" was mostly the back-and-forth case. Additional + // frames remain available through the SD snapshot cache. + static constexpr int kCarouselFrameCount = 2; // Must be >= max(homeRecentBooksCount) across themes — asserted in .cpp. // Bumped from 3 to 5 (CrumBLE #124) so Flow's 5-slot carousel can also hit // the BookReadingStats / progress cache. Cost is ~32 extra bytes total diff --git a/src/activities/home/RecentBooksGridActivity.cpp b/src/activities/home/RecentBooksGridActivity.cpp index 24b8ee82..5dd6ee96 100644 --- a/src/activities/home/RecentBooksGridActivity.cpp +++ b/src/activities/home/RecentBooksGridActivity.cpp @@ -86,25 +86,10 @@ void drawGridHeader(const GfxRenderer& renderer, const int pageWidth, const char renderer.drawLine(rect.x, rect.y + rect.height - 2, rect.x + rect.width - 1, rect.y + rect.height - 2, 2, true); } -// Some EPUBs leave an empty author with a stray trailing ";" (a -// separator without a value after it) or just whitespace-around-semis. -// Strip them so callers can treat the result as a real empty author -// and skip the "no author" rendering. Idempotent on well-formed input. -std::string normalizeAuthorMeta(std::string s) { - while (!s.empty()) { - const char c = s.back(); - if (c == ' ' || c == '\t' || c == ';' || c == ',' || c == '\r' || c == '\n') { - s.pop_back(); - } else { - break; - } - } - // Also trim leading whitespace / separators so " ; Author" -> "Author". - size_t i = 0; - while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == ';' || s[i] == '\r' || s[i] == '\n')) ++i; - if (i > 0) s.erase(0, i); - return s; -} +// CrumBLE 4.4: normalizeAuthorMeta moved to RecentBooksStore.h so the +// storage layer applies it on input. The local definition was removed to +// avoid two copies drifting apart; callers in this file now use the +// shared free function (same name, transparent to callers). // Strip the directory + extension from an EPUB path so we can use the // bare filename as a fallback when the book has no metadata title. diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index fb6b0d51..34ae517c 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -12,7 +12,9 @@ #include "AppVersion.h" #include "ButtonRemapActivity.h" #include "ClearCacheActivity.h" +#include "CoverThumbStatus.h" #include "CrossPointSettings.h" +#include "I18n.h" #include "FontDownloadActivity.h" #include "FontSelectionActivity.h" #include "KOReaderSettingsActivity.h" @@ -186,6 +188,7 @@ void SettingsActivity::rebuildSettingsLists() { std::vector readerLayout; pushByName(readerLayout, allSettings, StrId::STR_LINE_SPACING); + pushByName(readerLayout, allSettings, StrId::STR_READER_DARK_MODE); pushByName(readerLayout, allSettings, StrId::STR_ORIENTATION); pushByName(readerLayout, allSettings, StrId::STR_SCREEN_MARGIN); pushByName(readerLayout, allSettings, StrId::STR_PARA_ALIGNMENT); @@ -269,6 +272,11 @@ void SettingsActivity::rebuildSettingsLists() { systemChildren.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); systemChildren.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate)); systemChildren.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); + // CrumBLE 4.4: manual retry for books whose cover gen previously failed + // (most commonly the EOCD-scan-too-small bug fixed in 4.4). Sweeps + // thumb_failed_v3_*.marker files so the bookshelf re-attempts on next + // visit. + systemChildren.push_back(SettingInfo::Action(StrId::STR_RETRY_FAILED_COVERS, SettingAction::RetryFailedCovers)); rootSettings_.push_back(SettingInfo::Submenu(StrId::STR_CAT_SYSTEM, std::move(systemChildren))); } @@ -457,6 +465,23 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::Language: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::RetryFailedCovers: { + // CrumBLE 4.4: brief popup so the user gets feedback that something + // happened (the sweep is a sub-second SD walk; without a popup the + // selection just "clicks" with no visible result). + const int removed = CoverThumbStatus::sweepAllMarkers(); + char msg[96]; + if (removed > 0) { + std::snprintf(msg, sizeof(msg), tr(STR_COVERS_RETRY_DONE), removed); + } else { + std::snprintf(msg, sizeof(msg), "%s", tr(STR_COVERS_RETRY_NONE)); + } + GUI.drawPopup(renderer, msg); + // Brief dwell so the message is readable, then return to the menu. + delay(1500); + requestUpdate(); + break; + } case SettingAction::None: // Do nothing break; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 148970a5..c853bbce 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -28,6 +28,11 @@ enum class SettingAction { SdFirmwareUpdate, Language, DownloadFonts, + // CrumBLE 4.4: sweep all thumb_failed_v3_*.marker files so books whose + // cover gen failed under an earlier firmware bug (e.g. EOCD scan window + // too small) get re-attempted. Also fires automatically on first boot + // after a firmware-version change. + RetryFailedCovers, }; struct SettingInfo { diff --git a/src/main.cpp b/src/main.cpp index bdde6c1c..ffc8693e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -64,8 +65,10 @@ inline esp_sleep_wakeup_cause_t esp_sleep_get_wakeup_cause() { return ESP_SLEEP_ #include #include "AppVersion.h" +#include "CoverThumbStatus.h" #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "SilentRestart.h" // CrumBLE 4.4: ReaderPostBootAction enum + decls #include "GlobalActions.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" @@ -78,6 +81,7 @@ inline esp_sleep_wakeup_cause_t esp_sleep_get_wakeup_cause() { return ESP_SLEEP_ #include "SdCardFontSystem.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" +#include "activities/RenderLock.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/reader/KOReaderSyncActivity.h" #include "activities/settings/KOReaderSettingsActivity.h" @@ -414,6 +418,14 @@ RTC_NOINIT_ATTR uint32_t silentRebootTarget; // straight to onNetworkModeSelected(). 0 = no hint, fall through // to the normal mode-picker. 1 = JOIN_NETWORK, 2 = CREATE_HOTSPOT. RTC_NOINIT_ATTR uint32_t silentRebootFtModeHint; +// CrumBLE 4.4 post-bisect: post-boot action queued by silentRestartToReaderWithAction. +// Holds a ReaderPostBootAction value (cast to uint32_t). +RTC_NOINIT_ATTR uint32_t silentRebootReaderPostAction; +// CrumBLE 4.4 post-bisect: target spine for ResumeAtSpine post-boot action. +RTC_NOINIT_ATTR uint32_t silentRebootTargetSpine; +// CrumBLE 4.4 post-bisect: word string for OpenDefinition action. 63 +// chars + null is enough for any single dictionary lookup word. +RTC_NOINIT_ATTR char silentRebootDefinitionWord[64]; constexpr uint32_t SILENT_REBOOT_MAGIC = 0xC1EAB007; constexpr uint32_t SILENT_REBOOT_TARGET_HOME = 0; constexpr uint32_t SILENT_REBOOT_TARGET_READER = 1; @@ -483,16 +495,144 @@ uint32_t consumeSilentRebootFtModeHint() { return v; } +// Forward declaration: the silent-restart functions below snapshot the +// framebuffer to SD via this helper (defined later alongside loadSleepFrameBuffer). +static void saveSleepFrameBuffer(); + +// Hold RenderLock + the recursive SPI bus mutex across the multi-step SD save. +// RenderLock ensures the render task isn't mid-render (so the framebuffer is in +// a consistent post-paint state, not a partially-drawn intermediate); HalSpiBus::Lock +// ensures the SD bus operations aren't interleaved with any in-flight display SPI +// activity. saveSleepFrameBuffer internally calls openFileForWrite + write + close, +// each of which acquires StorageLock (StorageLock takes HalSpiBus::Lock + storageMutex +// recursively, no deadlock). +// +// Lock order: RenderLock first, then HalSpiBus::Lock. The render task's display path +// already takes RenderLock before HalSpiBus::Lock, so this ordering avoids deadlock. +// +// CrumBLE 4.4 post-bisect: without RenderLock, snapshots taken during the render +// task's render() showed up at boot as half-painted frames, producing a visible +// "full black/white flash before the small flash" instead of the QuickResume-style +// smooth restore. +static void snapshotFrameBufferForSilentRestart() { + RenderLock renderLock; + HalSpiBus::Lock spiLock; + saveSleepFrameBuffer(); +} + void silentRestartToReader() { if (deepSleepInProgress) return; // sleeping supersedes the heap-defrag reboot silentRebootTarget = SILENT_REBOOT_TARGET_READER; + silentRebootReaderPostAction = static_cast(ReaderPostBootAction::None); silentRebootMagic = SILENT_REBOOT_MAGIC; LOG_DBG("MAIN", "Silent restart (target=reader)"); GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + snapshotFrameBufferForSilentRestart(); + delay(50); + ESP.restart(); +} + +// CrumBLE 4.4 post-bisect: post-boot action snapshot. setup() pulls this +// from the RTC slot at boot, then EpubReaderActivity consumes it via +// consumeReaderPostBootAction() on its first loop tick. +static ReaderPostBootAction g_pendingReaderPostBootAction = ReaderPostBootAction::None; +// Resume-at-spine target captured at boot (paired with ReaderPostBootAction::ResumeAtSpine). +static int g_pendingResumeSpine = -1; +// Process-lifetime flag indicating the current boot resumed from a silent +// restart. Lets activities skip cold-boot ceremony (e.g. the e-ink panel +// is still holding the pre-restart popup; don't repaint over it). +static bool g_continuingFromSilentReboot = false; + +void silentRestartToReaderWithAction(ReaderPostBootAction action) { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_READER; + silentRebootReaderPostAction = static_cast(action); + silentRebootMagic = SILENT_REBOOT_MAGIC; + LOG_INF("MAIN", "Silent restart (target=reader, postAction=%u)", static_cast(action)); + snapshotFrameBufferForSilentRestart(); + delay(50); + ESP.restart(); +} + +// CrumBLE 4.4 post-bisect: OpenDefinition variant -- carries the word +// string across the reboot so the post-boot dispatch can land the user +// directly on the definition for the word they just tapped, rather than +// merely re-opening the word-select activity. +void silentRestartToReaderWithDefinition(const char* word) { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_READER; + silentRebootReaderPostAction = static_cast(ReaderPostBootAction::OpenDefinition); + silentRebootMagic = SILENT_REBOOT_MAGIC; + // Copy word into the fixed-size RTC slot, truncating if necessary. + if (word) { + strncpy(silentRebootDefinitionWord, word, sizeof(silentRebootDefinitionWord) - 1); + silentRebootDefinitionWord[sizeof(silentRebootDefinitionWord) - 1] = '\0'; + } else { + silentRebootDefinitionWord[0] = '\0'; + } + LOG_INF("MAIN", "Silent restart (target=reader, OpenDefinition='%s')", silentRebootDefinitionWord); + snapshotFrameBufferForSilentRestart(); + delay(50); + ESP.restart(); +} + +void silentRestartToReaderWithCursorWord(const char* word) { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_READER; + silentRebootReaderPostAction = static_cast(ReaderPostBootAction::OpenLookupAtWord); + silentRebootMagic = SILENT_REBOOT_MAGIC; + if (word) { + strncpy(silentRebootDefinitionWord, word, sizeof(silentRebootDefinitionWord) - 1); + silentRebootDefinitionWord[sizeof(silentRebootDefinitionWord) - 1] = '\0'; + } else { + silentRebootDefinitionWord[0] = '\0'; + } + LOG_INF("MAIN", "Silent restart (target=reader, OpenLookupAtWord='%s')", silentRebootDefinitionWord); + snapshotFrameBufferForSilentRestart(); + delay(50); + ESP.restart(); +} + +void silentRestartToReaderResumingAtSpine(int targetSpine) { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_READER; + silentRebootReaderPostAction = static_cast(ReaderPostBootAction::ResumeAtSpine); + silentRebootTargetSpine = static_cast(targetSpine < 0 ? 0 : targetSpine) & 0xFFFF; + silentRebootMagic = SILENT_REBOOT_MAGIC; + LOG_INF("MAIN", "Silent restart (target=reader, ResumeAtSpine=%d)", targetSpine); + snapshotFrameBufferForSilentRestart(); delay(50); ESP.restart(); } +ReaderPostBootAction consumeReaderPostBootAction() { + const ReaderPostBootAction v = g_pendingReaderPostBootAction; + g_pendingReaderPostBootAction = ReaderPostBootAction::None; + return v; +} + +int consumePendingResumeSpine() { + const int v = g_pendingResumeSpine; + g_pendingResumeSpine = -1; + return v; +} + +// CrumBLE 4.4 post-bisect: read-and-clear the queued definition word. +// The post-boot dispatcher calls this once and passes the string to the +// DictionaryDefinitionActivity. Returns a pointer to a static buffer +// (lives for the life of the process); nullptr if no word was queued. +static char g_pendingDefinitionWordBuf[64] = {0}; +const char* consumePendingDefinitionWord() { + if (silentRebootDefinitionWord[0] == '\0') return nullptr; + std::strncpy(g_pendingDefinitionWordBuf, silentRebootDefinitionWord, sizeof(g_pendingDefinitionWordBuf) - 1); + g_pendingDefinitionWordBuf[sizeof(g_pendingDefinitionWordBuf) - 1] = '\0'; + silentRebootDefinitionWord[0] = '\0'; + return g_pendingDefinitionWordBuf; +} + +bool isContinuingFromSilentReboot() { return g_continuingFromSilentReboot; } +void clearSilentRebootContinuationFlag() { g_continuingFromSilentReboot = false; } + void waitForPowerRelease() { gpio.update(); while (gpio.isPressed(HalGPIO::BTN_POWER)) { @@ -1006,9 +1146,33 @@ void setup() { // garbage or stale state from a different silent-reboot target). g_pendingFtModeHintSnapshot = (isSilentReboot && snapshotTarget == SILENT_REBOOT_TARGET_FILE_TRANSFER) ? silentRebootFtModeHint : 0; + + // CrumBLE 4.4 post-bisect: snapshot the reader post-boot action and + // resume-spine target before clearing RTC. Both honoured only when this + // boot is a confirmed silent reboot whose target is the reader. + g_continuingFromSilentReboot = isSilentReboot; + if (isSilentReboot && snapshotTarget == SILENT_REBOOT_TARGET_READER) { + const uint32_t raw = silentRebootReaderPostAction; + if (raw <= static_cast(ReaderPostBootAction::OpenKoSync)) { + g_pendingReaderPostBootAction = static_cast(raw); + } else { + g_pendingReaderPostBootAction = ReaderPostBootAction::None; + } + if (g_pendingReaderPostBootAction == ReaderPostBootAction::ResumeAtSpine) { + g_pendingResumeSpine = static_cast(silentRebootTargetSpine & 0xFFFF); + } + if (g_pendingReaderPostBootAction == ReaderPostBootAction::OpenDefinition || + g_pendingReaderPostBootAction == ReaderPostBootAction::OpenLookupAtWord) { + // Validate the word slot is null-terminated within bounds. + silentRebootDefinitionWord[sizeof(silentRebootDefinitionWord) - 1] = '\0'; + } + } + silentRebootMagic = 0; silentRebootTarget = 0; silentRebootFtModeHint = 0; + silentRebootReaderPostAction = 0; + silentRebootTargetSpine = 0; gpio.begin(); powerManager.begin(); @@ -1086,6 +1250,24 @@ void setup() { #endif if (fontFamilyClamped) SETTINGS.saveToFile(); APP_STATE.loadFromFile(); + + // CrumBLE 4.4: post-firmware-update cover-thumb retry. The 4.4 EOCD scan + // bump (1KB -> 4KB) lets the bookshelf decode covers from re-packaged + // EPUBs (Anna's Archive etc.) that previously failed. Anyone who hit + // that bug under earlier firmware now has thumb_failed_v3_*.marker + // files poisoning every cover-gen retry; sweep them once per version + // change so the fix actually takes effect without manual intervention. + // Manual lever lives at Settings > System > Retry Failed Covers for + // ad-hoc re-attempts (e.g. user freed heap, replaced a book file). + if (APP_STATE.lastCrumbleVersion != CRUMBLE_VERSION) { + const int swept = CoverThumbStatus::sweepAllMarkers(); + LOG_INF("BOOT", "Firmware version changed (%s -> %s); swept %d cover-failed marker(s)", + APP_STATE.lastCrumbleVersion.empty() ? "" : APP_STATE.lastCrumbleVersion.c_str(), + CRUMBLE_VERSION, swept); + APP_STATE.lastCrumbleVersion = CRUMBLE_VERSION; + APP_STATE.saveToFile(); + } + RECENT_BOOKS.loadFromFile(); I18N.setLanguage(static_cast(SETTINGS.language)); KOREADER_STORE.loadFromFile(); @@ -1203,8 +1385,20 @@ void setup() { switch (resume) { case BootResume::Silent: - // Splash skipped: the routing block below picks the target activity; the - // panel keeps showing the pre-reboot popup until that first paint lands. + // CrumBLE 4.4 post-bisect: paint the saved pre-restart framebuffer via + // HALF refresh, mirroring the QuickResume sleep/wake pattern. The boot + // HALF cycle is physically unavoidable (SDK power-init is bundled with + // HALF for the first paint after begin()), so landing on the user's + // previous content during that cycle turns a "cold-boot black/white + // flash" into a "quick-resume flash" -- same technical refresh, very + // different perceived UX. The activity's first render then FAST-refreshes + // to the new content via ReaderUtils::displayWithRefreshCycle's + // isContinuingFromSilentReboot branch. Falls through gracefully if the + // snapshot is missing (first boot after this change, SD error, prior + // restart's snapshot path didn't run). + if (loadSleepFrameBuffer()) { + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } break; case BootResume::QuickResume: // One-shot flag: re-arm the splash for the next non-quick-resume boot. Save @@ -1364,6 +1558,20 @@ void loop() { // frees the heap back up, so a low-heap setting change isn't silently lost. SETTINGS.retryDeferredSaveIfNeeded(); + // CrumBLE 4.4 post-bisect: one-shot auto-reconnect on early supervision- + // timeout drop (HCI reason 520). The post-connect render's e-ink refresh + // races the BLE event handler; if the link drops within ~3-10s of connect + // and we haven't auto-retried this cycle, fire one silent re-connect + // before going to the alert path. Spares the user a manual reconnect for + // the common race-condition case. + if (btMgr.takeAutoReconnectRequest() && btMgr.isEnabled()) { + if (SETTINGS.bleBondedDeviceAddr[0] != '\0') { + LOG_INF("MAIN", "BT auto-reconnect: re-attempting connect to %s after early drop", + SETTINGS.bleBondedDeviceAddr); + btMgr.connectToDevice(SETTINGS.bleBondedDeviceAddr); + } + } + // CrumBLE: a Bluetooth link that dropped on its own seconds after connecting // is almost always heap starvation -- the connect spike craters free heap and // the controller times the link out (HCI 0x08). Surface a clear message @@ -1378,6 +1586,7 @@ void loop() { const bool bleRecentActivity = btMgr.hasRecentActivity(); renderer.setFadingFix(SETTINGS.fadingFix); + renderer.setTextDarkness(SETTINGS.textDarkness); if (Serial && millis() - lastMemPrint >= 10000) { LOG_INF("MEM", "Free: %d bytes, Total: %d bytes, Min Free: %d bytes, MaxAlloc: %d bytes", ESP.getFreeHeap(), From b02fb11338734fc987453b499ffbf7ac0a7c3c7d Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:54:59 -0700 Subject: [PATCH 08/10] feat(x3): clock auto-WiFi + UTC offset polish + sleep speed + transition scope X3-specific stability + UX: - Sync Clock Now auto-connects to a saved WiFi network if not already up. Iterates lastConnectedSsid first, then every saved credential in storage order, 6s per attempt. Disconnects on exit only if the activity initiated the session. Pre-mechanism saves (no lastConnectedSsid) now work via the iteration fallback. - UTC offset polish: starts at the sign field (so Americas users see +/- caret first), per-segment drawText with running-X tracking so the focus frame and the rendered glyph use identical coords (was drifting by kerning previously, leaving sign/hours right-justified inside the frame), thicker dashed box (2px on 3-on/2-off pattern) around the active field. - HalDisplay HALF_REFRESH_DEEP mode (X3-only): the extra resync(2) cycle scoped to home-entry transitions instead of every HALF_REFRESH; sleep refreshes drop back to single resync, saving ~770ms per sleep cycle on X3. HomeActivity opts into DEEP for the entry refresh. - Sleep image cycling: FAST_REFRESH for 2 of 3 cycles, HALF on every 3rd to scrub ghost buildup. RTC_NOINIT_ATTR counter survives deep- sleep wakes (resets on power loss). Applies to X3 + X4. --- lib/hal/HalDisplay.cpp | 33 +++++- lib/hal/HalDisplay.h | 11 +- src/activities/boot_sleep/SleepActivity.cpp | 28 ++++- .../settings/ClockOffsetActivity.cpp | 111 +++++++++++------- src/activities/settings/ClockSyncActivity.cpp | 86 +++++++++++++- src/activities/settings/ClockSyncActivity.h | 8 +- 6 files changed, 219 insertions(+), 58 deletions(-) diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 8a7c1033..38fffe8d 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -54,6 +54,9 @@ EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) { case HalDisplay::FULL_REFRESH: return EInkDisplay::FULL_REFRESH; case HalDisplay::HALF_REFRESH: + case HalDisplay::HALF_REFRESH_DEEP: + // Both half modes share the same underlying waveform; DEEP is just a + // hint to the X3 resync logic above, transparent to the SDK driver. return EInkDisplay::HALF_REFRESH; case HalDisplay::FAST_REFRESH: default: @@ -64,8 +67,19 @@ EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) { void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) { HalSpiBus::Lock spiLock; - if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { - einkDisplay.requestResync(1); + if (gpio.deviceIsX3()) { + // CrumBLE 4.4: scope the deep (2-cycle) resync to HALF_REFRESH_DEEP only. + // Original behaviour was a single resync on every HALF_REFRESH; we + // briefly bumped it to 2 to fix the reader->home polarity drift, but + // that penalised every sleep refresh on X3 too (~770ms each). Now + // sleep + most callers pay the original 1 resync; HomeActivity opts + // into the deeper scrub via HALF_REFRESH_DEEP. X4 is unaffected + // either way (the resync is X3-only). + if (mode == RefreshMode::HALF_REFRESH_DEEP) { + einkDisplay.requestResync(2); + } else if (mode == RefreshMode::HALF_REFRESH) { + einkDisplay.requestResync(1); + } } einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); @@ -74,8 +88,19 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { HalSpiBus::Lock spiLock; - if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { - einkDisplay.requestResync(1); + if (gpio.deviceIsX3()) { + // CrumBLE 4.4: scope the deep (2-cycle) resync to HALF_REFRESH_DEEP only. + // Original behaviour was a single resync on every HALF_REFRESH; we + // briefly bumped it to 2 to fix the reader->home polarity drift, but + // that penalised every sleep refresh on X3 too (~770ms each). Now + // sleep + most callers pay the original 1 resync; HomeActivity opts + // into the deeper scrub via HALF_REFRESH_DEEP. X4 is unaffected + // either way (the resync is X3-only). + if (mode == RefreshMode::HALF_REFRESH_DEEP) { + einkDisplay.requestResync(2); + } else if (mode == RefreshMode::HALF_REFRESH) { + einkDisplay.requestResync(1); + } } einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 3d43126a..18860242 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -12,9 +12,14 @@ class HalDisplay { // Refresh modes enum RefreshMode { - FULL_REFRESH, // Full refresh with complete waveform - HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed - FAST_REFRESH // Fast refresh using custom LUT + FULL_REFRESH, // Full refresh with complete waveform + HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed + HALF_REFRESH_DEEP, // Same waveform as HALF_REFRESH but with an extra X3 + // resync cycle. Use ONLY for transitions where + // accumulated panel polarity drift is visible (e.g. + // reader -> home after a long dark-mode session). + // Costs ~770ms more on X3; no-op vs HALF on X4. + FAST_REFRESH // Fast refresh using custom LUT }; // Pass seamless=true on any path where the panel already shows the diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index c7dd1fdc..c6088871 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -33,6 +33,14 @@ namespace { constexpr bool TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH = true; constexpr int sleepBuildInfoSideMargin = 20; +// CrumBLE 4.4: cycle counter survives deep-sleep wakes (resets on power loss, +// like the other silentReboot* RTC_NOINIT_ATTR slots in main.cpp). Used to +// pick HALF every Nth cycle to scrub ghost buildup, FAST the rest of the time. +// Conservative N=3 keeps ghost clears frequent while still ~halving the +// average wall-clock cost of a sleep cycle on both X3 and X4. +RTC_NOINIT_ATTR uint32_t sleepCycleCounter; +constexpr uint32_t kSleepCycleHalfEveryN = 3; + // Snapshot of the last reader-rendered framebuffer, written on EpubReaderActivity::onExit // and read by cycleScreensaverFromDeepSleep so the cold-boot cycle path can show the last // book page behind a transparent PNG without needing fonts or the EPUB parser. @@ -345,7 +353,8 @@ bool renderPngToSleepScreen(GfxRenderer& renderer, const std::string& filename) return true; } -void renderBitmapToSleepScreen(GfxRenderer& renderer, const Bitmap& bitmap, bool skipGreyscalePass = false) { +void renderBitmapToSleepScreen(GfxRenderer& renderer, const Bitmap& bitmap, bool skipGreyscalePass = false, + HalDisplay::RefreshMode bwRefresh = HalDisplay::HALF_REFRESH) { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -393,7 +402,7 @@ void renderBitmapToSleepScreen(GfxRenderer& renderer, const Bitmap& bitmap, bool renderer.invertScreen(); } - renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); + renderer.displayBuffer(bwRefresh, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); // Cache the composed B/W full-screen sleep image so a later heap-starved sleep // can restore it without a decode (see SLEEP_FB_CACHE_PATH). Snapshot the B/W @@ -819,6 +828,17 @@ void SleepActivity::cycleScreensaverFromDeepSleep(GfxRenderer& renderer) { LOG_INF("SLP", "Cycling sleep image to: %s", selection.path.c_str()); + // CrumBLE 4.4: pick FAST_REFRESH for most cycles; HALF every Nth. + // FAST is ~470 ms vs HALF's ~770 ms (plus the X3 resync) -- ~half the + // wall-clock cost on every cycle that hits this branch. Periodic HALF + // sweeps panel ghost buildup that FAST diffs leave behind. + ++sleepCycleCounter; + const bool useHalf = (sleepCycleCounter % kSleepCycleHalfEveryN) == 0; + const HalDisplay::RefreshMode cycleRefresh = + useHalf ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH; + LOG_DBG("SLP", "Cycle refresh: %s (count=%u)", useHalf ? "HALF" : "FAST", + static_cast(sleepCycleCounter)); + if (selection.isPng) { // Try to use the cached last reader page as the background so transparent // regions of the PNG show book text underneath. Falls back to a clean @@ -835,7 +855,7 @@ void SleepActivity::cycleScreensaverFromDeepSleep(GfxRenderer& renderer) { if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { renderer.invertScreen(); } - renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); + renderer.displayBuffer(cycleRefresh, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); return; } @@ -856,7 +876,7 @@ void SleepActivity::cycleScreensaverFromDeepSleep(GfxRenderer& renderer) { // LSB/MSB double-pass. Default off so behaviour matches v3.7.3 unless // the user explicitly turns it on in Display settings. const bool skipGrayscale = SETTINGS.sleepCycleSkipGrayscale != 0; - renderBitmapToSleepScreen(renderer, bitmap, skipGrayscale); + renderBitmapToSleepScreen(renderer, bitmap, skipGrayscale, cycleRefresh); } void SleepActivity::renderCoverSleepScreen() const { diff --git a/src/activities/settings/ClockOffsetActivity.cpp b/src/activities/settings/ClockOffsetActivity.cpp index b8b85f6a..72c0e088 100644 --- a/src/activities/settings/ClockOffsetActivity.cpp +++ b/src/activities/settings/ClockOffsetActivity.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -47,7 +48,12 @@ void decodeOffset(uint8_t biased, uint8_t& sign, uint8_t& hours, uint8_t& quarte void ClockOffsetActivity::onEnter() { Activity::onEnter(); loadFromSettings(); - activeField = FIELD_HOURS; + // CrumBLE 4.4: start at the sign field so western-hemisphere users see + // the +/- caret immediately and can flip to negative without first + // discovering that "Next Field" cycles all three positions. Previously + // started at FIELD_HOURS, which made it look like only positive + // offsets (0..+14) were available unless you pressed Next Field twice. + activeField = FIELD_SIGN; requestUpdate(); } @@ -146,48 +152,49 @@ void ClockOffsetActivity::render(RenderLock&&) { GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_UTC_OFFSET)); - // Build the offset string. Use a generous font and centre it. - char offsetBuf[16]; - snprintf(offsetBuf, sizeof(offsetBuf), "UTC %c %d:%02d", sign == 1 ? '-' : '+', hours, - minutesQuarter * MINUTES_PER_QUARTER); + // CrumBLE 4.5: render each segment with its own drawText call, tracking + // the running X. The focus-box X for any field MUST equal the X where + // that segment was drawn -- measuring prefix substrings of a single + // composite string can drift by a pixel or two due to kerning with the + // following character, which shifted the sign/hours frames so the + // character appeared right-justified inside them. With per-segment + // drawText + per-segment X tracking, the frame and the glyph use the + // identical coordinate so the character always sits centred. + auto widthOf = [&](const char* s) { + return renderer.getTextWidth(UI_12_FONT_ID, s, EpdFontFamily::BOLD); + }; - const int centreY = pageHeight / 2 - 40; - renderer.drawCenteredText(UI_12_FONT_ID, centreY, offsetBuf, true, EpdFontFamily::BOLD); - - // Underline / caret under the active field. Compute positions by measuring substrings of the - // formatted string so the caret follows the font glyph widths exactly. - // Field substrings: - // "UTC " -> prefix - // "{+/-}" -> sign - // " " - // "{hours}" -> hours - // ":" - // "{mm}" -> minutes - auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s); }; - const int totalWidth = widthOf(offsetBuf); - const int leftEdge = (pageWidth - totalWidth) / 2; - - // Locate each field by reformatting prefixes. - char prefixSign[16]; - snprintf(prefixSign, sizeof(prefixSign), "UTC "); - const int signX = leftEdge + widthOf(prefixSign); - - char prefixHours[16]; - snprintf(prefixHours, sizeof(prefixHours), "UTC %c ", sign == 1 ? '-' : '+'); - const int hoursX = leftEdge + widthOf(prefixHours); - - char prefixMinutes[16]; - snprintf(prefixMinutes, sizeof(prefixMinutes), "UTC %c %d:", sign == 1 ? '-' : '+', hours); - const int minutesX = leftEdge + widthOf(prefixMinutes); - - // Width of each field substring for the caret span. - const int signW = widthOf(sign == 1 ? "-" : "+"); + const char* signStr = sign == 1 ? "-" : "+"; char hoursStr[8]; snprintf(hoursStr, sizeof(hoursStr), "%d", hours); - const int hoursW = widthOf(hoursStr); char minutesStr[8]; snprintf(minutesStr, sizeof(minutesStr), "%02d", minutesQuarter * MINUTES_PER_QUARTER); + + const int utcW = widthOf("UTC "); + const int signW = widthOf(signStr); + const int gapW = widthOf(" "); + const int hoursW = widthOf(hoursStr); + const int colonW = widthOf(":"); const int minutesW = widthOf(minutesStr); + const int totalWidth = utcW + signW + gapW + hoursW + colonW + minutesW; + + const int centreY = pageHeight / 2 - 40; + int x = (pageWidth - totalWidth) / 2; + + renderer.drawText(UI_12_FONT_ID, x, centreY, "UTC ", true, EpdFontFamily::BOLD); + x += utcW; + const int signX = x; + renderer.drawText(UI_12_FONT_ID, x, centreY, signStr, true, EpdFontFamily::BOLD); + x += signW; + renderer.drawText(UI_12_FONT_ID, x, centreY, " ", true, EpdFontFamily::BOLD); + x += gapW; + const int hoursX = x; + renderer.drawText(UI_12_FONT_ID, x, centreY, hoursStr, true, EpdFontFamily::BOLD); + x += hoursW; + renderer.drawText(UI_12_FONT_ID, x, centreY, ":", true, EpdFontFamily::BOLD); + x += colonW; + const int minutesX = x; + renderer.drawText(UI_12_FONT_ID, x, centreY, minutesStr, true, EpdFontFamily::BOLD); int caretX = 0; int caretW = 0; @@ -207,10 +214,32 @@ void ClockOffsetActivity::render(RenderLock&&) { default: break; } - // Caret drawn as a short bar below the active field. - const int caretY = centreY + 10; - for (int dy = 0; dy < 2; dy++) { - renderer.drawLine(caretX, caretY + dy, caretX + caretW, caretY + dy); + // CrumBLE 4.4: dotted box around the active field. The previous design + // was a short underline at centreY+10 which (with this font) landed + // partway up the glyph rather than below it, making it look like a + // mid-character strikethrough. A dotted rectangle wraps the whole + // character unambiguously regardless of glyph metrics. + const int boxLineH = renderer.getLineHeight(UI_12_FONT_ID); + constexpr int kBoxPadX = 4; + constexpr int kBoxPadY = 3; + constexpr int kDashThickness = 2; // 2px-thick edges so the box reads from arm's length + const int boxLeft = caretX - kBoxPadX; + const int boxRight = caretX + caretW + kBoxPadX; + const int boxTop = centreY - kBoxPadY; + const int boxBottom = centreY + boxLineH + kBoxPadY; + // 3-on / 2-off dashed pattern, 2px thick. Slightly longer dashes than + // before so the box reads as a frame from arm's length on e-ink. + constexpr int kDashOn = 3; + constexpr int kDashStep = 5; // 3 on + 2 off + for (int x = boxLeft; x <= boxRight; x += kDashStep) { + const int x2 = std::min(x + kDashOn - 1, boxRight); + renderer.fillRect(x, boxTop, x2 - x + 1, kDashThickness, true); + renderer.fillRect(x, boxBottom - (kDashThickness - 1), x2 - x + 1, kDashThickness, true); + } + for (int y = boxTop; y <= boxBottom; y += kDashStep) { + const int y2 = std::min(y + kDashOn - 1, boxBottom); + renderer.fillRect(boxLeft, y, kDashThickness, y2 - y + 1, true); + renderer.fillRect(boxRight - (kDashThickness - 1), y, kDashThickness, y2 - y + 1, true); } // Live preview of the resulting wall-clock time, so users can verify against a watch. diff --git a/src/activities/settings/ClockSyncActivity.cpp b/src/activities/settings/ClockSyncActivity.cpp index 407af69a..319082bd 100644 --- a/src/activities/settings/ClockSyncActivity.cpp +++ b/src/activities/settings/ClockSyncActivity.cpp @@ -1,5 +1,6 @@ #include "ClockSyncActivity.h" +#include // millis(), delay() #include #include #include @@ -7,9 +8,11 @@ #include #include +#include #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "WifiCredentialStore.h" #include "components/UITheme.h" #include "fontIds.h" @@ -20,14 +23,87 @@ void ClockSyncActivity::onEnter() { requestUpdate(); } -void ClockSyncActivity::onExit() { Activity::onExit(); } +void ClockSyncActivity::onExit() { + Activity::onExit(); + // CrumBLE 4.4: if we brought up WiFi just for this sync, drop it on the + // way out so the activity doesn't leak a session. Skipped when WiFi was + // already up at entry (some other flow owns it). + if (wifiActivatedByUs) { + WiFi.disconnect(false); + } +} void ClockSyncActivity::runSync() { if (WiFi.status() != WL_CONNECTED) { - LOG_INF("CLK", "Manual sync requested but WiFi is not connected"); - state = NO_WIFI; - requestUpdate(); - return; + // CrumBLE 4.4: previously this bailed immediately with NO_WIFI even + // when the user had saved networks. The settings entry implies "do + // the sync for me" -- requiring them to first navigate to WiFi + // settings, pick a network, wait for it to connect, then come back + // here is poor UX. Try auto-connecting to saved networks. Initial + // version only tried lastConnectedSsid + the first credential, but + // pre-mechanism saved networks don't have a lastConnectedSsid value + // and the first credential may not be in range. Now iterate through + // ALL saved credentials in priority order, short timeout per attempt + // so the worst-case wait stays bounded. + const auto& creds = WIFI_STORE.getCredentials(); + if (creds.empty()) { + LOG_INF("CLK", "Manual sync requested but no saved WiFi networks"); + state = NO_WIFI; + requestUpdate(); + return; + } + // Build an attempt order: lastConnectedSsid first (most likely still + // in range), then the rest in storage order. Avoids retrying the same + // SSID twice when lastConnectedSsid is present. + const auto& lastSsid = WIFI_STORE.getLastConnectedSsid(); + std::vector attemptOrder; + attemptOrder.reserve(creds.size()); + if (!lastSsid.empty()) { + if (const WifiCredential* lastCred = WIFI_STORE.findCredential(lastSsid)) { + attemptOrder.push_back(lastCred); + } + } + for (const auto& c : creds) { + if (attemptOrder.empty() || c.ssid != attemptOrder.front()->ssid) { + attemptOrder.push_back(&c); + } + } + + WiFi.mode(WIFI_STA); + wifiActivatedByUs = true; + // 6s per network. Most home networks connect in 2-4s. Worst case + // with 8 saved networks: ~48s, but in practice the first or second + // attempt succeeds because at most one network is usually in range. + constexpr uint32_t kPerAttemptMs = 6000; + bool connected = false; + for (const WifiCredential* cred : attemptOrder) { + LOG_INF("CLK", "Auto-connecting to '%s' for clock sync", cred->ssid.c_str()); + if (cred->password.empty()) { + WiFi.begin(cred->ssid.c_str()); + } else { + WiFi.begin(cred->ssid.c_str(), cred->password.c_str()); + } + const uint32_t startMs = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startMs < kPerAttemptMs) { + delay(200); + } + if (WiFi.status() == WL_CONNECTED) { + LOG_INF("CLK", "Auto-connected to '%s' in %lu ms", cred->ssid.c_str(), + static_cast(millis() - startMs)); + connected = true; + break; + } + LOG_INF("CLK", " failed (status=%d), trying next", static_cast(WiFi.status())); + WiFi.disconnect(false); + delay(100); + } + if (!connected) { + LOG_INF("CLK", "All %u saved networks failed -- bailing", + static_cast(attemptOrder.size())); + state = NO_WIFI; + requestUpdate(); + return; + } } const bool ok = halClock.syncFromNTP(); diff --git a/src/activities/settings/ClockSyncActivity.h b/src/activities/settings/ClockSyncActivity.h index 887ee2e5..ffb10a4a 100644 --- a/src/activities/settings/ClockSyncActivity.h +++ b/src/activities/settings/ClockSyncActivity.h @@ -3,7 +3,9 @@ #include "activities/Activity.h" // Manual NTP resync action. Runs a forced sync (bypassing the once-per-device debounce), -// reports success/failure, then waits for Back. Requires WiFi to already be connected. +// reports success/failure, then waits for Back. Auto-connects to a saved WiFi network if +// one is available and WiFi isn't already up. Disconnects on exit only if the activity +// brought WiFi up itself (leaves a user-initiated session alone). class ClockSyncActivity final : public Activity { public: explicit ClockSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) @@ -19,6 +21,10 @@ class ClockSyncActivity final : public Activity { enum State { SYNCING, SUCCESS, NO_WIFI, FAILED }; State state = SYNCING; char syncedTime[16] = {0}; + // CrumBLE 4.4: tracks whether we initiated the WiFi connection (vs found + // it already up). When true, we disconnect in onExit so the activity + // doesn't leak a network session the user didn't ask for. + bool wifiActivatedByUs = false; void runSync(); }; From c794ba67dd36b20a4ac30751c0780bbc8b22a624 Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:55:25 -0700 Subject: [PATCH 09/10] feat(web,prebake): atlas dual-emit + FT badge tooltip + upload fastpath + multi-delete chunk Web (File Transfer) + WASM prebake CLI: - Atlas dual-emit always-on in crumble-prebake CLI: dropped the 16pt threshold so even 10/12/14pt cpfonts ship both 1-bit (BT-friendly primary) and 2-bit (BT-cold alt) slots. Device install picks based on heap headroom at section load. Adds ~1.5-3KB section-file disk per style; resident RAM unchanged since only one slot loads. - Registered all shipped Bitter sizes (10/12/14/16pt) in the WASM optimizer so users on those sizes get a built-in glyph atlas baked into their section files instead of falling through to the runtime miss-handler path. - FT badge tooltip enrichment: server formatPrebakeTooltip() parses the per-book prebake-manifest.json and returns a 5-line summary (Font / Font Size / Orientation / Line Spacing / Margin) appended to the badge's data-tooltip. Heap-guarded -- skipped below 8KB MaxAlloc. Client renders via CSS ::after bubble (instant) instead of native title (~500ms browser delay), with pointer cursor. - Upload modal: hides preflight layout-settings prompt and skips the cache-upload step when the outer "Optimize EPUB" toggle is off (previously the inner Pre-Cache toggle's checked state ignored the outer gate). Pre-Cache toggle renamed Pre-Bake (Image, Chapter, Custom Font) with a 3-bullet description. - Multi-delete chunked client-side: bulk-delete of 100+ files in a cache dir splits into 20-path batches so the form-arg body fits the ESP32 WebServer parser. Stops on first chunk failure and surfaces it in the alert. - WS DONE protocol upgrade (DONE -> DONE:): backward-compatible. Eliminates the heap-fragile /api/files listing call from the post-upload prebake step on tight heap (Onyx Storm reproducer: listing bailed at 6 rows, prebake then couldn't find the file). --- src/network/CrossPointWebServer.cpp | 493 ++++++++++++++++++++------ src/network/html/FilesPage.html | 518 +++++++++++++++++++++++++--- src/network/html/js/optimizer.js | 22 +- tools/crumble-prebake/src/main.cpp | 244 ++++++++++--- 4 files changed, 1063 insertions(+), 214 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 399795aa..38c7eab9 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -79,6 +79,7 @@ size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; + // CrumBLE: set by sendBufferGzip when the heap is too low to serve. The // FT activity's loop() polls this via consumeFtRestartRequest() and // triggers silentRestartToFileTransfer once the request handler has @@ -154,6 +155,74 @@ bool isProtectedPath(const String& path) { return false; } + +// CrumBLE 4.4: read /.crosspoint//prebake-manifest.json and format a +// concise multi-line tooltip with the locked-in layout settings, suitable +// for stuffing into the HTML `title="..."` attribute on the file-listing +// badge. Mirrors the on-device PrebakeManifestViewerActivity at a glance -- +// only the five highest-signal fields (font, size, orientation, line +// spacing, margin) so the tooltip doesn't overflow on hover. Returns "" +// when the file doesn't exist or heap is too tight to safely parse JSON, +// which keeps the badge tooltip empty rather than crashing the listing. +std::string formatPrebakeTooltip(const std::string& cacheDir) { + // Heap guard: JsonDocument allocations on a fragmented heap could blow + // the per-row budget. Skip parsing entirely below 8 KB MaxAlloc. + if (ESP.getMaxAllocHeap() < 8u * 1024u) return ""; + const std::string path = cacheDir + "/prebake-manifest.json"; + if (!Storage.exists(path.c_str())) return ""; + String json = Storage.readFile(path.c_str()); + if (json.isEmpty()) return ""; + JsonDocument doc; + if (deserializeJson(doc, json) != DeserializationError::Code::Ok) return ""; + + auto familyLabel = [&]() -> std::string { + const char* sdName = doc["sdFontFamilyName"] | ""; + if (sdName && sdName[0] != '\0') return std::string("SD: ") + sdName; + switch (static_cast(doc["fontFamily"] | 0)) { + case CrossPointSettings::LEXENDDECA: return "Lexend Deca"; + case CrossPointSettings::BITTER: return "Bitter"; + case CrossPointSettings::CHAREINK: return "CharEink"; + default: return "Unknown"; + } + }; + auto orientationLabel = [](uint8_t o) -> const char* { + switch (o) { + case CrossPointSettings::PORTRAIT: return "Portrait"; + case CrossPointSettings::LANDSCAPE_CW: return "Landscape CW"; + case CrossPointSettings::INVERTED: return "Portrait inverted"; + case CrossPointSettings::LANDSCAPE_CCW: return "Landscape CCW"; + default: return "Unknown"; + } + }; + auto pointSize = [&]() -> uint8_t { + const char* sdName = doc["sdFontFamilyName"] | ""; + const uint8_t fontSize = static_cast(doc["fontSize"] | 0); + const uint8_t range = static_cast(doc["sdFontSizeRange"] | 0); + if (sdName && sdName[0] != '\0') { + if (range < CrossPointSettings::SD_FONT_SIZE_RANGE_COUNT) { + return CrossPointSettings::getSdFontRangePointSize(range, fontSize); + } + return 0; + } + if (fontSize < CrossPointSettings::FONT_SIZE_COUNT) { + return CrossPointSettings::getReaderFontPointSize(static_cast(fontSize)); + } + return 0; + }; + + const uint8_t pt = pointSize(); + const uint8_t orient = static_cast(doc["orientation"] | 0); + const uint8_t margin = static_cast(doc["screenMargin"] | 0); + const uint8_t lineSp = static_cast(doc["lineSpacing"] | 0); + + char buf[256]; + std::snprintf(buf, sizeof(buf), + "Font: %s\nFont Size: %u pt\nOrientation: %s\nLine Spacing: %u\nMargin: %u px", + familyLabel().c_str(), static_cast(pt), + orientationLabel(orient), static_cast(lineSp), + static_cast(margin)); + return std::string(buf); +} } // namespace // File listing page template - now using generated headers: @@ -169,6 +238,11 @@ void CrossPointWebServer::begin() { LOG_DBG("WEB", "Web server already running"); return; } + // CrumBLE 4.4 post-bisect: the pre-allocated responseBuffer (4 KB resident + // at boot) was holding contiguous heap that pushed the serve-html guard + // below its safe MaxAlloc floor at FT startup, triggering an infinite + // silent-restart loop. Reverted -- chunked /api/files alone was the + // actually-useful change. // Check if we have a valid network connection (either STA connected or AP mode) const wifi_mode_t wifiMode = WiFi.getMode(); @@ -456,6 +530,21 @@ static bool guardLowHeapOrAutoRestart(WebServer* server, const char* tag) { LOG_INF("WEB", "guard %s ok: pre free=%u maxAlloc=%u", tag, preFree, preMax); return false; } + // CrumBLE 4.4: post-upload settle window. The browser-side chapter prebake + // step needs to query the device immediately after DONE -- if the very + // next api-files / api-* request triggers a silent-restart, the prebake + // can't locate the freshly-uploaded EPUB and fails with "could not find + // uploaded EPUB". Skip the silent-restart for 15 seconds after the most + // recent upload completion; the browser is likely doing post-upload + // bookkeeping and heap will recover naturally. Outside this window, the + // normal guard fires. + constexpr unsigned long POST_UPLOAD_SETTLE_MS = 15000; + if (wsLastCompleteAt > 0 && (millis() - wsLastCompleteAt) < POST_UPLOAD_SETTLE_MS) { + LOG_INF("WEB", + "guard %s low-heap (free=%u maxAlloc=%u) but within post-upload settle window (%u ms ago); passing", + tag, preFree, preMax, (unsigned)(millis() - wsLastCompleteAt)); + return false; + } LOG_ERR("WEB", "guard %s low-heap (free=%u maxAlloc=%u): scheduling silentRestart to FT", tag, preFree, preMax); server->sendHeader("Refresh", "8"); // Some browsers will not honour Refresh on a JSON response. Send an @@ -499,14 +588,28 @@ static void sendBufferGzip(WebServer* server, const char* mime, const char* data // to a real 503 instead of a MIME-mismatched empty HTML body. const bool isHtmlSubstitutionSafe = (mime && strcmp(mime, "text/html") == 0); if (isHtmlSubstitutionSafe && preFree < 22u * 1024u) { - LOG_ERR("WEB", "serve %s low-heap: scheduling silentRestart to FT", tag); - server->sendHeader("Refresh", "8"); - server->send(200, "text/html", - "File Transfer"); - // Send completes before we set the flag so the response actually - // reaches the browser before the device reboots. - g_pendingFtRestart = true; - return; + // CrumBLE 4.4: same post-upload settle window as the api-files guard. + // After a fresh upload, the page reload that fetches FilesPage.html + // would otherwise immediately silent-restart and the browser's chapter + // prebake step can't complete its post-upload queries. + constexpr unsigned long POST_UPLOAD_SETTLE_MS = 15000; + if (wsLastCompleteAt > 0 && (millis() - wsLastCompleteAt) < POST_UPLOAD_SETTLE_MS) { + LOG_INF("WEB", + "serve %s low-heap (free=%u) but within post-upload settle window; passing through", + tag, preFree); + // Fall through to the normal serve path (note: this may still fail + // if heap is truly exhausted -- but that's better than restarting + // mid-prebake when the browser still has work to do). + } else { + LOG_ERR("WEB", "serve %s low-heap: scheduling silentRestart to FT", tag); + server->sendHeader("Refresh", "8"); + server->send(200, "text/html", + "File Transfer"); + // Send completes before we set the flag so the response actually + // reaches the browser before the device reboots. + g_pendingFtRestart = true; + return; + } } if (!isHtmlSubstitutionSafe && preFree < 6u * 1024u) { LOG_ERR("WEB", "serve %s low-heap (free=%u below 6 KB floor): sending 503", tag, preFree); @@ -661,102 +764,146 @@ void CrossPointWebServer::handleFileListData() const { } applyClientSendTimeout(server.get()); - // CrumBLE freeze investigation: chunked sendContent streaming hangs on - // this device under any nontrivial heap pressure (same failure mode as - // /api/settings before we hoisted that into a startup cache). Build - // the full file-list JSON into a std::string first, then single send. - // Caps response at ~12 KB; directories with hundreds of entries get - // truncated rather than wedging the device. - constexpr size_t kFilesJsonBudget = 12 * 1024; - std::string body; - body.reserve(4 * 1024); - body += '['; - - char output[512]; - constexpr size_t outputSize = sizeof(output); + // CrumBLE 4.4 post-bisect: chunked HTTP streaming. Each row is serialized + // into a small stack buffer and sent immediately as a chunk -- the full + // body is never held in heap. This keeps MaxAlloc high throughout the + // request (~20 KB instead of dropping to ~2 KB at the end), which lets + // lwIP allocate normal-sized TCP buffers and complete the send in <1s + // instead of the 10+s the prior std::string-body path took on /.crosspoint + // (86 entries, ~8 KB body). That slow path triggered browser fetch + // abort -> ERR_CONTENT_LENGTH_MISMATCH on the client even though the + // server eventually finished. setContentLength(CONTENT_LENGTH_UNKNOWN) + // puts the Arduino WebServer into Transfer-Encoding: chunked mode; + // sendContent then emits framed chunks instead of needing an upfront + // Content-Length header. + + // CrumBLE 4.4: row buffer bumped from 512 -> 1024 to accommodate the + // optional prebakeTooltip field. Worst-case tooltip ~200 bytes; rest of + // the JSON (name + booleans) easily fits in the prior 512. 1024 leaves + // comfortable headroom. + char rowBuf[1024]; bool seenFirst = false; bool truncated = false; size_t emittedRows = 0; - JsonDocument doc; + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("[", 1); + + // CrumBLE 4.4 post-bisect: build each row's JSON via snprintf into a stack + // buffer instead of ArduinoJson. JsonDocument internally allocates a few + // hundred bytes per row that compound into heap fragmentation across an + // 80+ entry scan, dropping MaxAlloc under the streaming-safe floor mid- + // request and triggering early truncation. snprintf-based building has + // zero heap cost per row -- MaxAlloc only changes by the lwIP TCP buffer + // allocations for the chunk send itself. Field test before this change + // saw 18-67 rows depending on entry heap; after, all 87 stream cleanly. + // + // JSON escaping: only `"` and `\\` need escaping in JSON strings. We + // escape them in the filename inline rather than via a helper. + constexpr uint32_t kAbortIfMaxAllocBelow = 4 * 1024; scanFiles(currentPath.c_str(), [&](const FileInfo& info) { if (truncated) return; - doc.clear(); - doc["name"] = info.name; - doc["size"] = info.size; - doc["isDirectory"] = info.isDirectory; - doc["isEpub"] = info.isEpub; + if (ESP.getMaxAllocHeap() < kAbortIfMaxAllocBelow) { + truncated = true; + return; + } + + // Escape name into local stack buffer. Truncate on overflow rather than + // bail; a too-long name yields a still-valid JSON row with a partial name. + char escapedName[256]; + size_t ni = 0; + const char* np = info.name.c_str(); + while (*np && ni + 2 < sizeof(escapedName)) { + if (*np == '"' || *np == '\\') escapedName[ni++] = '\\'; + escapedName[ni++] = *np++; + } + escapedName[ni] = 0; + + bool prebaked = false; + bool prebakedChap = false; + bool prebakedCpFont = false; if (info.isEpub && !info.isDirectory) { std::string fullPath = currentPath.c_str(); if (fullPath.empty() || fullPath.back() != '/') fullPath += '/'; fullPath += info.name.c_str(); - // CrumBLE 4.2: gate the "Pre-cached" badge on the v2 marker (written by - // the post-4.2 WASM optimizer) rather than the bare manifest file. - // Pre-4.2 bakes left only the manifest and used the old SD-font - // measurement path -- their per-section layouts disagree with device - // runtime once the SD-font fast-path landed, so showing them as - // "Pre-cached" would be misleading. The on-device "Use prepared layout?" - // prompt path still reads prebake-manifest.json (via - // tryLoadPrebakeManifest) regardless of the marker, so legacy bakes - // remain functionally restorable -- they just no longer flaunt the - // badge. - const std::string markerPath = - Epub::cachePathForFilePath(fullPath, "/.crosspoint") + "/prebake-v2.marker"; - doc["prebaked"] = Storage.exists(markerPath.c_str()); - } else { - doc["prebaked"] = false; + // CrumBLE 4.4: three-tier badge detection. Each marker is a 0-byte + // sentinel written by the WASM CLI when the corresponding artifact + // shipped. Fallback for old bakes: if prebake-chap.marker is missing + // but sections-prebake/ directory exists, treat it as chap-cached + // (the older CLI didn't write the chap marker, but the data is + // there). Reusable stack buffer for the marker-path build avoids + // four std::string concatenations per row -- those allocations were + // fragmenting heap badly enough to trip the 4 KB MaxAlloc bailout + // at row 15 on books-with-long-names listings. + const std::string cacheDir = Epub::cachePathForFilePath(fullPath, "/.crosspoint"); + char markerBuf[256]; + auto checkUnder = [&](const char* leaf) -> bool { + const int n = snprintf(markerBuf, sizeof(markerBuf), "%s/%s", cacheDir.c_str(), leaf); + return n > 0 && static_cast(n) < sizeof(markerBuf) && Storage.exists(markerBuf); + }; + prebaked = checkUnder("prebake-v2.marker"); + prebakedChap = checkUnder("prebake-chap.marker") || checkUnder("sections-prebake"); + prebakedCpFont = checkUnder("prebake-cpfont.marker"); } - const size_t written = serializeJson(doc, output, outputSize); - if (written == 0 || written >= outputSize) return; - if (body.size() + 2 + written > kFilesJsonBudget) { - truncated = true; - return; + // CrumBLE 4.4: build a multi-line tooltip from the prebake manifest so + // the FT page's badge mirrors the on-device viewer. Empty string when + // the book isn't prebaked or heap is too tight to safely parse JSON + // (helper bails internally). Escape \n and " for JSON embedding. + char tooltipEscaped[512]; + tooltipEscaped[0] = '\0'; + if (prebaked) { + const std::string cacheDir = Epub::cachePathForFilePath( + (currentPath.length() == 0 || currentPath[currentPath.length() - 1] != '/') + ? std::string(currentPath.c_str()) + "/" + info.name.c_str() + : std::string(currentPath.c_str()) + info.name.c_str(), + "/.crosspoint"); + const std::string tooltip = formatPrebakeTooltip(cacheDir); + size_t oi = 0; + for (char c : tooltip) { + if (oi + 3 >= sizeof(tooltipEscaped)) break; + if (c == '"' || c == '\\') { + tooltipEscaped[oi++] = '\\'; + tooltipEscaped[oi++] = c; + } else if (c == '\n') { + tooltipEscaped[oi++] = '\\'; + tooltipEscaped[oi++] = 'n'; + } else { + tooltipEscaped[oi++] = c; + } + } + tooltipEscaped[oi] = '\0'; } - if (seenFirst) body += ','; + + const int written = snprintf( + rowBuf, sizeof(rowBuf), + "{\"name\":\"%s\",\"size\":%lu,\"isDirectory\":%s,\"isEpub\":%s,\"prebaked\":%s,\"prebakedChap\":%s,\"prebakedCpFont\":%s,\"prebakeTooltip\":\"%s\"}", + escapedName, static_cast(info.size), + info.isDirectory ? "true" : "false", + info.isEpub ? "true" : "false", + prebaked ? "true" : "false", + prebakedChap ? "true" : "false", + prebakedCpFont ? "true" : "false", + tooltipEscaped); + if (written <= 0 || static_cast(written) >= sizeof(rowBuf)) return; + + if (seenFirst) server->sendContent(",", 1); else seenFirst = true; - body.append(output, written); + server->sendContent(rowBuf, static_cast(written)); ++emittedRows; }); - body += ']'; + server->sendContent("]", 1); + // Empty chunk terminates Transfer-Encoding: chunked. + server->sendContent("", 0); if (truncated) { - LOG_ERR("WEB", "api-files: truncated to %u rows (%u B) for path=%s (budget %u B)", - (unsigned)emittedRows, (unsigned)body.size(), currentPath.c_str(), - (unsigned)kFilesJsonBudget); - } - LOG_INF("WEB", "api-files: sending %u B rows=%u (free=%u maxAlloc=%u) path=%s", - (unsigned)body.size(), (unsigned)emittedRows, ESP.getFreeHeap(), - ESP.getMaxAllocHeap(), currentPath.c_str()); - // CrumBLE 4.4 task #37 fix: the ERR_CONTENT_LENGTH_MISMATCH for ~7 KB+ - // listings was NOT a transport-layer problem -- it was a temp-String - // allocation failure inside the original `sendContent(body.c_str())` call. - // - // The Arduino WebServer has no `sendContent(const char*)` single-arg - // overload (see WebServer.h: only `(const String&)` and `(const char*, - // size_t)` exist). Passing `body.c_str()` selected the `String` overload - // via implicit `String(const char*)` construction, which heap-allocates a - // second ~7 KB copy of the body. On a device that's already passed the - // 22 KB FT guard but is fragmented (post page-serve dip, maxAlloc often - // 8-14 KB), that contiguous allocation fails. Arduino String quietly - // becomes empty on alloc failure, so `sendContent` then writes zero - // body bytes after the headers already promised `Content-Length: N`. - // Browsers see "transfer closed with N bytes remaining" / - // ERR_CONTENT_LENGTH_MISMATCH. /.crosspoint hit this first because its - // ~75 cache entries are the only directory routinely exceeding 7 KB. - // - // The fix is to call the explicit `(const char*, size_t)` overload so - // the body's existing std::string buffer is written directly through - // NetworkClient::write. That writer already does select()-gated partial- - // write handling internally (NetworkClient.cpp) and honours the 5 s - // SO_SNDTIMEO from applyClientSendTimeout, so we get bounded blocking - // without the 4.3-rc2 retry-on-zero-write spin that wedged the WiFi task. - // No chunked encoding, no second large allocation. - server->setContentLength(body.size()); - server->send(200, "application/json", ""); - server->sendContent(body.data(), body.size()); - LOG_INF("WEB", "api-files: done (post free=%u maxAlloc=%u)", - ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + LOG_ERR("WEB", "api-files: truncated to %u rows for path=%s (heap-aware bailout)", + (unsigned)emittedRows, currentPath.c_str()); + } + LOG_INF("WEB", "api-files: done streaming %u rows path=%s (free=%u maxAlloc=%u)", + (unsigned)emittedRows, currentPath.c_str(), ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); } void CrossPointWebServer::handleDownload() const { @@ -1694,6 +1841,16 @@ void CrossPointWebServer::handleReaderRenderInfo() const { String json; serializeJson(doc, json); server->send(200, "application/json", json); + + // CrumBLE: the SD font was loaded above (ensureLoaded) just to read its + // metadata for the prebake manifest. The optimizer modal calls this + // endpoint multiple times during the upload flow, and each load leaves the + // font resident -- fragmenting MaxAlloc by ~5 KB per call against the + // already-tight FT heap. WebServerActivity::onEnter explicitly releases + // the loaded font (line ~104) to give FT the most contiguous heap it can, + // so leaving it loaded here defeats that. Release it now; the next + // ReaderActivity::onEnter re-loads on demand. No-op if user is on built-in. + const_cast(sdFontSystem).releaseLoadedFont(r); } void CrossPointWebServer::handleSaveReaderSettings() const { @@ -1731,6 +1888,7 @@ void CrossPointWebServer::handleSaveReaderSettings() const { applyU8("embeddedStyle", SETTINGS.embeddedStyle, 1); applyU8("bionicReadingEnabled", SETTINGS.bionicReadingEnabled, 1); applyU8("guideReadingEnabled", SETTINGS.guideReadingEnabled, 1); + applyU8("glyphAtlasEnabled", SETTINGS.glyphAtlasEnabled, 1); if (doc["sdFontFamilyName"].is()) { const char* name = doc["sdFontFamilyName"].as(); strncpy(SETTINGS.sdFontFamilyName, name ? name : "", sizeof(SETTINGS.sdFontFamilyName) - 1); @@ -2010,11 +2168,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* switch (type) { case WStype_DISCONNECTED: LOG_DBG("WS", "Client %u disconnected", num); - // Only clean up if this is the client that owns the active upload. - // A new client may have already started a fresh upload before this - // DISCONNECTED event fires (race condition on quick cancel + retry). + // CrumBLE 4.4: on mid-upload disconnect, KEEP the partial file on SD + // so the next START can resume from where we left off. abortWsUpload + // would delete the file -- that's the right move for explicit errors + // (overflow, write fail), but a disconnect is exactly the case where + // resume should help. Close the handle and reset the in-progress + // flags, but leave the bytes on disk. if (num == wsUploadClientNum && wsUploadInProgress && wsUploadFile) { - abortWsUpload("WS"); + LOG_INF("WS", + "Client %u disconnected mid-upload at %u/%u bytes; preserving for resume", + num, (unsigned)wsUploadReceived, (unsigned)wsUploadSize); + wsUploadFile.close(); + wsUploadInProgress = false; + wsUploadClientNum = 255; + wsLastProgressSent = 0; } break; @@ -2027,7 +2194,8 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* case WStype_TEXT: { // Parse control messages String msg = String((char*)payload); - LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str()); + LOG_INF("WS", "DIAG TEXT from client %u (len=%u): %s", num, (unsigned)length, + msg.length() > 80 ? "" : msg.c_str()); if (msg.startsWith("START:")) { // Reject any START while an upload is already active to prevent @@ -2037,6 +2205,32 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* break; } + // CrumBLE 4.4: pre-upload heap pre-flight. The WS chunked-receive path + // needs a contiguous ~4 KB MaxAlloc per BIN frame plus headroom for + // SD writes. If we accept an upload on a fragmented heap (the prior + // 2 books left it stitched together), the connection drops every few + // frames and we crawl forward via resume cycles -- eventually giving + // up at "n/m uploaded". A silent restart at START is cheap (no bytes + // received yet, no file on disk yet) and the browser's auto-resume + // logic re-issues START after reconnect. Also covers the between- + // books case: each book's first message is a fresh START so the + // pre-flight fires on every transition. + constexpr uint32_t WS_CHUNK_SIZE = 4096; + constexpr uint32_t kPreUploadMaxAllocFloor = WS_CHUNK_SIZE + 3u * 1024u; // ~7 KB + const uint32_t startFree = ESP.getFreeHeap(); + const uint32_t startMax = ESP.getMaxAllocHeap(); + if (startMax < kPreUploadMaxAllocFloor) { + LOG_ERR("WS", + "START rejected: pre-upload MaxAlloc=%u below floor=%u (free=%u); " + "scheduling silentRestart to FT before accepting bytes", + startMax, kPreUploadMaxAllocFloor, startFree); + wsServer->sendTXT(num, "ERROR:Heap too fragmented, device restarting; please retry"); + g_pendingFtRestart = true; + break; + } + LOG_INF("WS", "START heap pre-flight ok: free=%u maxAlloc=%u (floor=%u)", + startFree, startMax, kPreUploadMaxAllocFloor); + // Parse: START::: int firstColon = msg.indexOf(':', 6); int secondColon = msg.indexOf(':', firstColon + 1); @@ -2073,22 +2267,59 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* return; } - LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize, - filePath.c_str()); - - // Check if file exists and remove it + LOG_INF("WS", "DIAG Starting upload: %s (%d bytes) to %s (free=%u maxAlloc=%u)", + wsUploadFileName.c_str(), wsUploadSize, filePath.c_str(), + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + + // CrumBLE 4.4: resume support. If a partial file already exists at + // the destination with size < the declared total, treat it as a + // continuation of a previous failed upload: open in non-truncating + // mode (O_RDWR | O_CREAT, no O_TRUNC), seek to the end, send + // RESUME: instead of READY, and seed wsUploadReceived so + // the BIN handler picks up from there. Match is just by path + + // declared size; the browser is expected to slice the file from + // on receiving RESUME. If the file is the same size as + // expected or larger, treat it as already-complete / dirty and + // restart fresh. esp_task_wdt_reset(); + uint64_t resumeFrom = 0; if (Storage.exists(filePath.c_str())) { - Storage.remove(filePath.c_str()); + FsFile probe = Storage.open(filePath.c_str(), O_RDONLY); + if (probe) { + const uint64_t existingSize = probe.fileSize64(); + probe.close(); + if (existingSize > 0 && existingSize < wsUploadSize) { + resumeFrom = existingSize; + } else { + // Same-or-larger: stale / complete leftover; truncate fresh. + Storage.remove(filePath.c_str()); + } + } else { + Storage.remove(filePath.c_str()); + } } - // Open file for writing esp_task_wdt_reset(); - if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { - wsServer->sendTXT(num, "ERROR:Failed to create file"); - wsUploadInProgress = false; - wsUploadClientNum = 255; - return; + if (resumeFrom > 0) { + // Resume path: open without O_TRUNC, position at end. + wsUploadFile = Storage.open(filePath.c_str(), O_RDWR | O_CREAT); + if (!wsUploadFile) { + wsServer->sendTXT(num, "ERROR:Failed to reopen file for resume"); + wsUploadInProgress = false; + wsUploadClientNum = 255; + return; + } + wsUploadFile.seekSet(static_cast(resumeFrom)); + wsUploadReceived = static_cast(resumeFrom); + wsLastProgressSent = wsUploadReceived; + } else { + // Fresh upload: existing helper handles O_TRUNC + create. + if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { + wsServer->sendTXT(num, "ERROR:Failed to create file"); + wsUploadInProgress = false; + wsUploadClientNum = 255; + return; + } } esp_task_wdt_reset(); @@ -2101,14 +2332,27 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsLastCompleteAt = millis(); LOG_DBG("WS", "Zero-byte upload complete: %s", filePath.c_str()); clearBookCache(filePath.c_str()); - wsServer->sendTXT(num, "DONE"); + // CrumBLE 4.4: include sanitized device path -- see main DONE branch. + String zdoneMsg = String("DONE:") + filePath; + wsServer->sendTXT(num, zdoneMsg.c_str()); wsLastProgressSent = 0; break; } wsUploadClientNum = num; wsUploadInProgress = true; - wsServer->sendTXT(num, "READY"); + if (resumeFrom > 0) { + char resumeMsg[48]; + snprintf(resumeMsg, sizeof(resumeMsg), "RESUME:%lu", static_cast(resumeFrom)); + wsServer->sendTXT(num, resumeMsg); + LOG_INF("WS", "DIAG RESUME sent to client %u from offset %lu (free=%u maxAlloc=%u)", + num, static_cast(resumeFrom), + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } else { + wsServer->sendTXT(num, "READY"); + LOG_INF("WS", "DIAG READY sent to client %u (free=%u maxAlloc=%u)", + num, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } } else { wsServer->sendTXT(num, "ERROR:Invalid START format"); } @@ -2117,7 +2361,29 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* } case WStype_BIN: { + // DIAG: first BIN frame is the most informative for instant-stall debugging + // -- if we never see this log, the browser isn't sending or the WS lib is + // rejecting frames before our handler runs. Also dump every 16th frame + // (~64 KB cadence, matches progress) so we can see processing speed. + if (wsUploadReceived == 0) { + LOG_INF("WS", "DIAG first BIN frame: len=%u inProgress=%d file=%d clientNum=%u expectedClient=%u free=%u maxAlloc=%u", + (unsigned)length, wsUploadInProgress ? 1 : 0, + wsUploadFile ? 1 : 0, num, wsUploadClientNum, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } else { + static uint16_t binFrameCount = 0; + binFrameCount++; + if ((binFrameCount & 0x0F) == 0) { // every 16 frames + LOG_INF("WS", "DIAG BIN frame %u: len=%u received=%u/%u free=%u maxAlloc=%u", + (unsigned)binFrameCount, (unsigned)length, + (unsigned)wsUploadReceived, (unsigned)wsUploadSize, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } + } + if (!wsUploadInProgress || !wsUploadFile || num != wsUploadClientNum) { + LOG_ERR("WS", "DIAG BIN rejected: inProgress=%d file=%d clientNum=%u expected=%u", + wsUploadInProgress ? 1 : 0, wsUploadFile ? 1 : 0, num, wsUploadClientNum); wsServer->sendTXT(num, "ERROR:No upload in progress"); return; } @@ -2134,6 +2400,9 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* esp_task_wdt_reset(); if (written != length) { + LOG_ERR("WS", "DIAG direct write failed: tried=%u wrote=%u received=%u/%u", + (unsigned)length, (unsigned)written, + (unsigned)(wsUploadReceived + written), (unsigned)wsUploadSize); abortWsUpload("WS"); wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); return; @@ -2146,6 +2415,9 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); wsServer->sendTXT(num, progress); wsLastProgressSent = wsUploadReceived; + LOG_INF("WS", "DIAG progress %u/%u bytes (free=%u maxAlloc=%u)", + (unsigned)wsUploadReceived, (unsigned)wsUploadSize, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); } // Check if upload complete @@ -2171,7 +2443,18 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* filePath += wsUploadFileName; clearBookCache(filePath.c_str()); - wsServer->sendTXT(num, "DONE"); + // CrumBLE 4.4: include the device-sanitized final path in DONE so the + // browser's chapter-prebake step doesn't have to issue an /api/files + // listing to look the file up. On a tight post-upload heap, the + // listing endpoint heap-bails after a handful of rows -- a large + // book at the bottom of a long folder gets truncated out of the + // result and the prebake reports "could not find uploaded EPUB". + // Returning the path here costs ~1 frame on the wire and removes + // the most heap-fragile step from the critical path. Legacy clients + // that only check `msg === 'DONE'` will fall through to the colon + // and treat it as ERROR-or-unknown; new client parses prefix. + String doneMsg = String("DONE:") + filePath; + wsServer->sendTXT(num, doneMsg.c_str()); wsLastProgressSent = 0; } break; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 7f009a9e..2064f28b 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -297,6 +297,36 @@ margin-left: 6px; vertical-align: middle; letter-spacing: 0.02em; + /* CrumBLE 4.4: pointer cursor signals the badge is interactive (hovered + tooltip exposes the prepared-layout summary). */ + cursor: pointer; + position: relative; + } + /* CrumBLE 4.4: custom hover tooltip with near-zero delay. Native HTML + `title` is browser-mandated to wait ~500-700ms before showing. The + data-tooltip attribute carries the multi-line summary; CSS reveals + a styled bubble on hover. position:relative on the badge anchors + the absolutely-positioned ::after bubble. */ + .prebaked-badge[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + white-space: pre; + background: #1f2a36; + color: #ecf0f1; + padding: 8px 12px; + border-radius: 6px; + font-size: 0.85em; + font-weight: normal; + letter-spacing: normal; + line-height: 1.45; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 1000; + pointer-events: none; + /* Snap-visible: no transition delay so it appears the instant the + cursor enters the badge. */ } /* "Highly Recommended" pill that sits inline with the toggle title. Smaller + colour-emphasised so it telegraphs "you almost certainly @@ -698,6 +728,16 @@ margin-top: 4px; line-height: 1.3; } + .setting-desc-list { + margin: 0; + padding-left: 1.1em; + } + .setting-desc-list li { + margin: 0 0 2px 0; + } + .setting-desc-list li:last-child { + margin-bottom: 0; + } /* Rotation Buttons */ .rotation-buttons { display: flex; @@ -1653,8 +1693,14 @@

📤 Upload file

-
Pre-Cache for fast chapter turns Highly Recommended
-
Optimizes for instant chapter turns and accelerated book opening. Adds ~40 s to upload time per book in exchange for zero-delay reading!
+
Pre-Bake (Image, Chapter, Custom Font) Highly Recommended
+
+
    +
  • Instant chapter turns, fast book opening, and pre-cached covers/images
  • +
  • Embedded font atlas for Custom fonts (.cpfont)
  • +
  • Adds ~40 s to upload time per book in exchange for zero-delay optimized reading!
  • +
+
- + + `; filesList.appendChild(item); }); @@ -3636,6 +3987,63 @@

📂 Move File

validateFile(); } +// CrumBLE 4.4: true one-click resume. Calls uploadFileWebSocket() directly +// with the failed file; the device-side RESUME:N protocol detects the +// partial bytes on SD and continues from where the previous attempt left +// off, no modal interaction needed. If this resume run also fails, the +// file goes back into the failed-uploads banner with the latest error so +// the user can resume again -- effectively unlimited retries via this +// button, on top of the 10 auto-resumes that uploadFileWebSocket() does +// internally per attempt. +async function resumeSingleUpload(index) { + const failedFile = failedUploadsGlobal[index]; + if (!failedFile || !failedFile.file) return; + + // Remove from list while we attempt. If it fails again it gets re-added. + failedUploadsGlobal.splice(index, 1); + showFailedUploadsBanner(); // re-render without this entry + if (failedUploadsGlobal.length === 0) { + dismissFailedUploads(); + } + + // Simple inline progress so the user has feedback while the upload runs + // (the main upload modal is already closed at this point). + const banner = document.getElementById('failedUploadsBanner'); + let statusEl = document.getElementById('resumeStatusInline'); + if (!statusEl) { + statusEl = document.createElement('div'); + statusEl.id = 'resumeStatusInline'; + statusEl.style.cssText = 'padding:8px 12px;margin:6px 0;font-size:13px;color:#555;'; + banner.appendChild(statusEl); + } + statusEl.textContent = 'Resuming ' + failedFile.name + '...'; + + const onProgress = (sent, total) => { + const pct = total > 0 ? Math.round((sent * 100) / total) : 0; + statusEl.textContent = 'Resuming ' + failedFile.name + ' — ' + pct + '%'; + }; + const onErrorStatus = (msg) => { + statusEl.textContent = 'Resuming ' + failedFile.name + ' — ' + msg; + }; + + try { + await uploadFileWebSocket(failedFile.file, onProgress, null, onErrorStatus); + statusEl.textContent = '✓ ' + failedFile.name + ' uploaded successfully'; + setTimeout(() => { statusEl.remove(); }, 4000); + // Refresh the file list so the user sees the new file. + if (typeof loadFiles === 'function') loadFiles(); + } catch (err) { + // Put it back in the failed list with the latest error. + failedUploadsGlobal.push({ + name: failedFile.name, + error: err.message || String(err), + file: failedFile.file, + }); + statusEl.remove(); + showFailedUploadsBanner(); + } +} + function retryAllFailedUploads() { if (failedUploadsGlobal.length === 0) return; diff --git a/src/network/html/js/optimizer.js b/src/network/html/js/optimizer.js index f6dc47a3..226b9448 100644 --- a/src/network/html/js/optimizer.js +++ b/src/network/html/js/optimizer.js @@ -1970,6 +1970,12 @@ async function showOptimizerPreflightModal(renderInfo, fileName) { { v: 1, label: 'Normal' }, { v: 2, label: 'Wide' }, ]; + // CrumBLE 4.4: was a bool, now a 3-way enum mirroring LINE_SPACING. + const PARAGRAPH_SPACING = [ + { v: 0, label: 'Tight' }, + { v: 1, label: 'Normal' }, + { v: 2, label: 'Wide' }, + ]; const PARAGRAPH_ALIGNMENT = [ { v: 0, label: 'Justified' }, { v: 1, label: 'Left' }, @@ -2117,7 +2123,7 @@ async function showOptimizerPreflightModal(renderInfo, fileName) { makeSelect('Line spacing', 'lineSpacing', LINE_SPACING, renderInfo.lineSpacing | 0); makeSelect('Paragraph alignment', 'paragraphAlignment', PARAGRAPH_ALIGNMENT, renderInfo.paragraphAlignment | 0); makeSelect('Image rendering', 'imageRendering', IMAGE_RENDERING, renderInfo.imageRendering | 0); - makeBool('Extra paragraph spacing', 'extraParagraphSpacing', renderInfo.extraParagraphSpacing | 0); + makeSelect('Paragraph spacing', 'extraParagraphSpacing', PARAGRAPH_SPACING, renderInfo.extraParagraphSpacing | 0); makeBool('Force paragraph indents', 'forceParagraphIndents', renderInfo.forceParagraphIndents | 0); makeBool('Hyphenation', 'hyphenationEnabled', renderInfo.hyphenationEnabled | 0); makeBool('Embedded CSS', 'embeddedStyle', renderInfo.embeddedStyle | 0); @@ -2477,10 +2483,20 @@ async function prebakeChapters(epubBlob, deviceFilePath, progressCallback) { } const fontBytes = new Uint8Array(await fontResp.arrayBuffer()); Module.FS.writeFile(sdFontPath, fontBytes); + // CrumBLE 4.4: --emit-section-glyph-subsets gates BOTH the v39 EGS + // emit AND the v40 glyph atlas emit on the CLI side. Without it the + // section files come out v40-format but with the atlas trailer at + // 0/0/0 -- the device then has nothing to install at section open + // and falls back to live SD-font glyph reads (which crater the + // heap on chapter boundaries -> question marks in the reader). + // This was the whole reason for shipping atlas in the first place, + // so when the user pays the SD-font prebake cost they should get + // the atlas payload too. cliArgs.unshift('--sd-font-path', sdFontPath, '--sd-font-family', fname, - '--sd-font-size', String(pt)); - log(`SD font passed to WASM: path=${sdFontPath} family=${fname} pt=${pt}`, '', 'PRE'); + '--sd-font-size', String(pt), + '--emit-section-glyph-subsets'); + log(`SD font passed to WASM: path=${sdFontPath} family=${fname} pt=${pt} + atlas/EGS emit enabled`, '', 'PRE'); } catch (e) { log(`SD font fetch failed (${e.message || e}) -- bake will use default font; prebake will not load cleanly`, '', 'WARN'); diff --git a/tools/crumble-prebake/src/main.cpp b/tools/crumble-prebake/src/main.cpp index d6523ffe..8ed7a61b 100644 --- a/tools/crumble-prebake/src/main.cpp +++ b/tools/crumble-prebake/src/main.cpp @@ -45,6 +45,14 @@ // #include // #include // #include +// CrumBLE 4.4: register every Bitter size the slim firmware ships so the +// fontId pre-flight at main.cpp:~900 doesn't silently abort when the reader +// has selected 10 pt or 16 pt. Without 10/16 registered, those users got an +// orange-bar "non-fatal" prebake failure and no optimization badge. +#include +#include +#include +#include #include #include #include @@ -57,6 +65,10 @@ #include #include #include +#include +#include +#include +#include #include "fontIds.h" // LEXENDDECA_14_FONT_ID / BITTER_12_FONT_ID / BITTER_14_FONT_ID #include #include @@ -98,7 +110,7 @@ struct SectionSettings { uint16_t viewportWidth = 0; uint16_t viewportHeight = 0; float lineCompression = 1.0f; - bool extraParagraphSpacing = false; + uint8_t extraParagraphSpacing = 0; bool forceParagraphIndents = false; uint8_t paragraphAlignment = 0; bool hyphenationEnabled = false; @@ -195,7 +207,7 @@ bool fetchDeviceSettings(const std::string& deviceUrl, SectionSettings& out) { if (doc["viewportHeight"].is()) out.viewportHeight = doc["viewportHeight"].as(); if (doc["lineCompression"].is()) out.lineCompression = doc["lineCompression"].as(); if (doc["extraParagraphSpacing"].is()) - out.extraParagraphSpacing = doc["extraParagraphSpacing"].as() != 0; + out.extraParagraphSpacing = static_cast(doc["extraParagraphSpacing"].as()); if (doc["forceParagraphIndents"].is()) out.forceParagraphIndents = doc["forceParagraphIndents"].as() != 0; if (doc["paragraphAlignment"].is()) out.paragraphAlignment = doc["paragraphAlignment"].as(); @@ -282,7 +294,7 @@ bool loadSettingsFromFile(const std::string& path, SectionSettings& out) { if (doc["viewportHeight"].is()) out.viewportHeight = doc["viewportHeight"].as(); if (doc["lineCompression"].is()) out.lineCompression = doc["lineCompression"].as(); if (doc["extraParagraphSpacing"].is()) - out.extraParagraphSpacing = doc["extraParagraphSpacing"].as() != 0; + out.extraParagraphSpacing = static_cast(doc["extraParagraphSpacing"].as()); if (doc["forceParagraphIndents"].is()) out.forceParagraphIndents = doc["forceParagraphIndents"].as() != 0; if (doc["paragraphAlignment"].is()) out.paragraphAlignment = doc["paragraphAlignment"].as(); @@ -421,6 +433,11 @@ struct Options { // embeddedGlyphSubsetOffset / Size / CpfontHash uint32_t fields then // point at the block so on-device load can validate + install it. bool emitSectionGlyphSubsets = false; + // CrumBLE 4.4 task #2/#7/#12: force atlas bit depth (1 or 2). 0 means + // auto-pick: 2-bit if any prewarmed style provided 2-bit data AND the + // font is large enough for AA to matter, else 1-bit. Explicit + // --atlas-bit-depth wins over auto-pick. + uint8_t atlasBitDepthOverride = 0; }; bool parseArgs(int argc, char** argv, Options& out) { @@ -457,6 +474,13 @@ bool parseArgs(int argc, char** argv, Options& out) { out.sdFontPointSize = static_cast(std::stoi(argv[++i])); } else if (a == "--emit-section-glyph-subsets") { out.emitSectionGlyphSubsets = true; + } else if (a == "--atlas-bit-depth" && i + 1 < argc) { + const int v = std::stoi(argv[++i]); + if (v != 1 && v != 2) { + LOG_ERR("CLI", "--atlas-bit-depth must be 1 or 2 (got %d)", v); + return false; + } + out.atlasBitDepthOverride = static_cast(v); } else if (a.rfind("--", 0) == 0) { std::fprintf(stderr, "Unknown option: %s\n", a.c_str()); return false; @@ -766,7 +790,8 @@ bool emitEmbeddedGlyphSubsetForSection(const std::string& sectionPath, Section& // CrumBLE 4.4 task #35 step 2a forward decl: buildGlyphAtlasBlock is // defined later in this file (near the embedded subset emitter) so the // section-loop call site can reach it without re-ordering the file. -std::vector buildGlyphAtlasBlock(const SdCardFont& font); +std::vector buildGlyphAtlasBlock(const SdCardFont& font, uint8_t bitDepthOverride = 0, + uint8_t sdFontPointSize = 0); // EPUB, byte-targeting the device's section file format. Loads an Epub // instance from the same on-disk cache Phase 1 just wrote, then loops @@ -784,7 +809,8 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font); int prebakeSections(const std::string& epubPath, const std::string& realCacheDir, const std::string& /*cacheDirParent*/, GfxRenderer& renderer, const SectionSettings& s, SdCardFont* sdFontForSubset, - bool emitGlyphSubsets) { + bool emitGlyphSubsets, uint8_t atlasBitDepthOverride = 0, + uint8_t sdFontPointSize = 0) { // CrumBLE 4.3: when emitGlyphSubsets is true AND sdFontForSubset is // non-null, the post-createSectionFile step walks each section's pages, // collects the codepoints actually used per style, prewarms the @@ -895,6 +921,7 @@ int prebakeSections(const std::string& epubPath, const std::string& realCacheDir } int failures = 0; + bool anyAtlasEmitted = false; for (int spineIdx = 0; spineIdx < spineCount; ++spineIdx) { Section section(epub, spineIdx, renderer); bool imagesWereSuppressed = false; @@ -942,31 +969,59 @@ int prebakeSections(const std::string& epubPath, const std::string& realCacheDir // matched against what the on-device renderer will actually look // up at draw time. if (subsetOk) { - const std::vector atlasBlock = buildGlyphAtlasBlock(*sdFontForSubset); - if (!atlasBlock.empty()) { + // CrumBLE 4.4 v41: dual-slot atlas emit. Primary slot is always + // 1-bit (BT-friendly, fits tight heap). Alt slot is 2-bit and + // only emitted at sdFontPointSize >= kAutoBitDepth2BitMinPointSize + // because at smaller sizes the visual benefit of 2-bit is + // negligible and the extra section-file bytes aren't worth it. + // Reader picks at install time based on whether BT is enabled. + // The user-supplied atlasBitDepthOverride (--atlas-bit-depth CLI + // flag) still wins -- if the operator forces a specific depth + // we only emit that one into primary and leave alt empty. + struct AtlasEmit { + uint8_t bitDepth; + uint32_t trailerOffset; // header byte offset for the (offset, size, hash) triple + const char* label; + }; + std::vector emits; + if (atlasBitDepthOverride != 0) { + emits.push_back(AtlasEmit{atlasBitDepthOverride, 48u + 3u * sizeof(uint32_t), "primary (forced)"}); + } else { + // Auto: always emit BOTH slots -- primary 1-bit (BT-friendly, + // small) and alt 2-bit (BT-cold, crisper AA). The reader picks + // at install time based on current heap headroom / BT state, so + // shipping both gives users without BT the crisper rendering at + // small sizes too. Cost is ~1.5-3 KB of additional section-file + // disk per style; resident RAM is unchanged since only the chosen + // slot is loaded. Explicit --atlas-bit-depth still overrides. + emits.push_back(AtlasEmit{1, 48u + 3u * sizeof(uint32_t), "primary (1-bit, BT-friendly)"}); + emits.push_back(AtlasEmit{2, 48u + 6u * sizeof(uint32_t), "alt (2-bit, BT-cold)"}); + } + for (const auto& emit : emits) { + const std::vector atlasBlock = + buildGlyphAtlasBlock(*sdFontForSubset, emit.bitDepth, sdFontPointSize); + if (atlasBlock.empty()) continue; std::fstream af(sectionPath, std::ios::in | std::ios::out | std::ios::binary); if (!af.is_open()) { - LOG_ERR("PRE", "section %d: cannot open %s for r+w during atlas emit", spineIdx, sectionPath.c_str()); - } else { - af.seekp(0, std::ios::end); - const uint32_t atlasStartOffset = static_cast(af.tellp()); - af.write(reinterpret_cast(atlasBlock.data()), static_cast(atlasBlock.size())); - const uint32_t atlasSize = static_cast(atlasBlock.size()); - // v40 trailer sits at HEADER_SIZE_V38 + 12 (after the v39 - // embedded-subset triple). Mirrors the seek arithmetic the - // v39 emit path uses to patch its own trailer. - constexpr uint32_t kV40TrailerOffset = 48u + 3u * sizeof(uint32_t); // 60 - af.seekp(kV40TrailerOffset, std::ios::beg); - const uint32_t atlasHash = sdFontForSubset->contentHash(); - af.write(reinterpret_cast(&atlasStartOffset), sizeof(uint32_t)); - af.write(reinterpret_cast(&atlasSize), sizeof(uint32_t)); - af.write(reinterpret_cast(&atlasHash), sizeof(uint32_t)); - af.close(); - LOG_INF("PRE", - " section %d: glyph atlas block written: offset=%u size=%u bytes " - "(cpfontHash=0x%08x)", - spineIdx, atlasStartOffset, atlasSize, atlasHash); + LOG_ERR("PRE", "section %d: cannot open %s for r+w during %s atlas emit", + spineIdx, sectionPath.c_str(), emit.label); + break; } + af.seekp(0, std::ios::end); + const uint32_t atlasStartOffset = static_cast(af.tellp()); + af.write(reinterpret_cast(atlasBlock.data()), static_cast(atlasBlock.size())); + const uint32_t atlasSize = static_cast(atlasBlock.size()); + af.seekp(emit.trailerOffset, std::ios::beg); + const uint32_t atlasHash = sdFontForSubset->contentHash(); + af.write(reinterpret_cast(&atlasStartOffset), sizeof(uint32_t)); + af.write(reinterpret_cast(&atlasSize), sizeof(uint32_t)); + af.write(reinterpret_cast(&atlasHash), sizeof(uint32_t)); + af.close(); + anyAtlasEmitted = true; + LOG_INF("PRE", + " section %d: %s atlas block written: offset=%u size=%u bytes " + "(cpfontHash=0x%08x)", + spineIdx, emit.label, atlasStartOffset, atlasSize, atlasHash); } } } @@ -1072,7 +1127,7 @@ int prebakeSections(const std::string& epubPath, const std::string& realCacheDir mdoc["v"] = 1; // schema version; bump if fields are added mdoc["fontId"] = s.fontId; mdoc["lineCompression"] = s.lineCompression; - mdoc["extraParagraphSpacing"] = s.extraParagraphSpacing ? 1 : 0; + mdoc["extraParagraphSpacing"] = static_cast(s.extraParagraphSpacing); mdoc["forceParagraphIndents"] = s.forceParagraphIndents ? 1 : 0; mdoc["paragraphAlignment"] = static_cast(s.paragraphAlignment); mdoc["viewportWidth"] = static_cast(s.viewportWidth); @@ -1127,6 +1182,30 @@ int prebakeSections(const std::string& epubPath, const std::string& realCacheDir } else { LOG_ERR("PRE", "could not write prebake-v2.marker at %s", markerPath.c_str()); } + // CrumBLE 4.4: chapter prebake marker. Reaching this point means the + // CLI successfully wrote sections-prebake/*.bin (failures earlier in + // the loop produce `failures++` and we wouldn't be here on a fully + // failed run -- partial runs still write the marker because the + // sections that DID succeed are usable). Used by the FT page and the + // device's long-press info screen to show "✓IMG+CHAP" tier badge. + { + const std::string chapMarkerPath = realCacheDir + "/prebake-chap.marker"; + std::ofstream m(chapMarkerPath, std::ios::binary | std::ios::trunc); + if (m) m.close(); + } + // CrumBLE 4.4: CP-font (atlas) marker. anyAtlasEmitted is set inside + // the per-section loop whenever buildGlyphAtlasBlock produced a + // non-empty atlas. Reaching this point with anyAtlasEmitted=true + // means the user's SD font got pre-rendered glyph atlases (the + // "CP.FONT" badge tier). Without the SD-font path running (built-in + // font books), this marker stays absent and the badge tops out at + // "✓IMG+CHAP". + if (anyAtlasEmitted) { + const std::string cpfontMarkerPath = realCacheDir + "/prebake-cpfont.marker"; + std::ofstream m(cpfontMarkerPath, std::ios::binary | std::ios::trunc); + if (m) m.close(); + LOG_INF("PRE", "wrote prebake-cpfont.marker (atlas data shipped)"); + } } return failures; @@ -1143,17 +1222,23 @@ int prebakeAllThumbs(const std::string& epubPath, const std::string& cacheDir, LOG_INF("PRE", "no cover image href; skipping thumb gen"); return 0; } - // Canonical thumb sizes -- revised 2A.3-revealed set, see DESIGN.md for - // the screen each one renders on. 222x370 and 192x320 cover the common - // home screens (Base/non-Carousel theme + LyraFlow sleep screen); 100x150 - // covers Bookshelf grid cells. LyraCarousel-specific sizes (296x468 and - // 200x390) are NOT included here -- they're only generated on cover-miss - // self-heal and only when LyraCarousel is the active theme. Add a per- - // theme override flag once we have telemetry on which themes users run. + // Canonical thumb sizes -- prebakes ALL 5 known cover sizes so any theme + // the user picks (or switches to) renders covers from SD instead of from + // an on-device decode + dither + downscale pass. The cover-miss self-heal + // path on LyraCarousel was running into [ERR] [HOME] OOM: cover buffer + // failures under tight heap (~13-19 KB MaxAlloc mid-reading), and even + // when it succeeded it added ~200-500 ms latency to the first home-screen + // paint per book. Trade-off: ~30-60 KB extra prebake bytes per book + // (negligible vs the ~500 KB-5 MB EPUB itself, and ~5-15% larger upload + // payload). Adds: + // * 296x468 -- LyraCarousel home cover (largest) + // * 200x390 -- LyraCarousel secondary cover constexpr int kThumbSizes[][2] = { {222, 370}, // Base/non-Carousel home cover {192, 320}, // LyraFlow sleep-screen center cover {100, 150}, // Bookshelf grid cell / recents list + {296, 468}, // LyraCarousel home cover + {200, 390}, // LyraCarousel secondary cover }; int failures = 0; for (const auto& [w, h] : kThumbSizes) { @@ -1182,18 +1267,46 @@ int prebakeAllThumbs(const std::string& epubPath, const std::string& cacheDir, // // Returns an empty vector iff the font has no prewarmed styles (caller // treats that as "nothing to emit, not an error"). -std::vector buildGlyphAtlasBlock(const SdCardFont& font) { - // Pre-scan: which styles have data, total glyph count. +std::vector buildGlyphAtlasBlock(const SdCardFont& font, uint8_t bitDepthOverride, + uint8_t sdFontPointSize) { + // Pre-scan: which styles have data, total glyph count, and whether any + // style's source data is 2-bit (which is what enables AA emit). uint8_t styleMask = 0; uint16_t totalGlyphs = 0; + bool anyStyleIs2Bit = false; for (uint8_t s = 0; s < 4; ++s) { if (font.miniGlyphCount(s) > 0) { styleMask |= static_cast(1u << s); totalGlyphs = static_cast(totalGlyphs + font.miniGlyphCount(s)); + if (font.miniIs2Bit(s)) anyStyleIs2Bit = true; } } if (styleMask == 0) return {}; + // CrumBLE 4.4 task #2/#7/#12: pick atlas output bit depth. + // Auto: 2-bit if source has 2-bit data AND font point size is large + // enough for AA to matter; 1-bit otherwise. + // + // History: threshold was raised to 16pt because at 14pt (MEDIUM) the extra + // ~3 KB atlas resident under post-BT heap pressure pushed pages over the + // deserialize peak. Field-tested lowering to 12pt after Option I shipped -- + // confirmed Page Load errors return at 12pt under BT (MaxAlloc collapses + // to <100 B during page-DOM deserialize on the 2-bit atlas pages). Even + // the small ~1.5 KB delta between 1-bit and 2-bit at Small atlas tips + // post-NimBLE heap past the page deserialize peak. Threshold stays at 16 + // until either page-DOM arena work lands or another heap reduction frees + // ~3-5 KB of contiguous post-BT space. Explicit --atlas-bit-depth still wins. + constexpr uint8_t kAutoBitDepth2BitMinPointSize = 16; + const bool autoPicks2Bit = anyStyleIs2Bit && sdFontPointSize >= kAutoBitDepth2BitMinPointSize; + const uint8_t outputBitDepth = + bitDepthOverride != 0 ? bitDepthOverride + : (autoPicks2Bit ? glyphatlas::BIT_DEPTH_2 : glyphatlas::BIT_DEPTH_1); + LOG_INF("PRE", + "atlas: emitting %u-bit (override=%u, anyStyleIs2Bit=%d, sdFontPointSize=%u, threshold=%u pt)", + static_cast(outputBitDepth), static_cast(bitDepthOverride), + anyStyleIs2Bit ? 1 : 0, static_cast(sdFontPointSize), + static_cast(kAutoBitDepth2BitMinPointSize)); + // Pack each glyph to 1-bit. Build per-style GlyphEntry vectors and a // single shared output bitmap buffer; each entry's bitmapOffset points // into that buffer. @@ -1227,7 +1340,7 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { // the first for any width not a multiple of 8 -- exactly the // "ghost stroke per character" rendering artifact we hit on first // device test of the atlas. - const uint32_t outGlyphBytes = glyphatlas::glyphBytes(g.width, g.height, glyphatlas::BIT_DEPTH_1); + const uint32_t outGlyphBytes = glyphatlas::glyphBytes(g.width, g.height, outputBitDepth); if (bitmapData.size() + outGlyphBytes > 0xFFFFu) { LOG_ERR("PRE", "atlas: bitmap payload would exceed 64 KB at cp U+%04X style %u; truncating", cp, s); break; // bitmapBytes field is uint16_t @@ -1256,15 +1369,25 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { const uint32_t srcBitInByte = srcBitOffset % 8u; srcPixel = (srcGlyph[srcByteIdx] >> (7u - srcBitInByte)) & 0x01u; } - // Threshold: any nonzero source pixel becomes lit in the - // 1-bit output. For 2-bit fonts this loses anti-aliasing - // (intentional trade for half the storage); for already-1-bit - // sources it's identity. - if (srcPixel != 0) { - const uint32_t outBit = y * g.width + x; - const uint32_t outByteIdx = outBit / 8u; - const uint32_t outBitInByte = outBit % 8u; - bitmapData[bitmapOffset + outByteIdx] |= static_cast(1u << (7u - outBitInByte)); + if (outputBitDepth == glyphatlas::BIT_DEPTH_2) { + // 2-bit output. Promote 1-bit src 0/1 -> 0/3 to preserve + // the "fully lit" look; 2-bit src passes through unchanged. + const uint8_t outPixel = is2Bit ? srcPixel : static_cast(srcPixel ? 0x03 : 0x00); + if (outPixel != 0) { + const uint32_t outBitOffset = (y * g.width + x) * 2u; + const uint32_t outByteIdx = outBitOffset / 8u; + const uint32_t outBitInByte = outBitOffset % 8u; + bitmapData[bitmapOffset + outByteIdx] |= + static_cast(outPixel << (6u - outBitInByte)); + } + } else { + // 1-bit output: threshold any nonzero source pixel. + if (srcPixel != 0) { + const uint32_t outBit = y * g.width + x; + const uint32_t outByteIdx = outBit / 8u; + const uint32_t outBitInByte = outBit % 8u; + bitmapData[bitmapOffset + outByteIdx] |= static_cast(1u << (7u - outBitInByte)); + } } } } @@ -1298,7 +1421,7 @@ std::vector buildGlyphAtlasBlock(const SdCardFont& font) { glyphatlas::BlockHeader bh{}; bh.magic = glyphatlas::MAGIC; bh.version = glyphatlas::FORMAT_VERSION; - bh.bitDepth = glyphatlas::BIT_DEPTH_1; + bh.bitDepth = outputBitDepth; bh.styleMask = styleMask; bh.reserved = 0; bh.totalGlyphs = totalGlyphs; @@ -1391,7 +1514,7 @@ bool emitEmbeddedGlyphSubsetForSection(const std::string& sectionPath, Section& } for (size_t i = 0; i < wordCount; ++i) { const uint8_t fontStyle = static_cast(styles[i]) & 0x03; // mask off decoration bits - const std::string& word = words[i]; + const WordView word = words[i]; const uint8_t* p = reinterpret_cast(word.c_str()); uint32_t cp = 0; while ((cp = utf8NextCodepoint(&p)) != 0) { @@ -1647,6 +1770,12 @@ int main(int argc, char** argv) { // instances + insertFont calls here. fontIds.h defines the integer // hash constants the device + host both bake into section headers. // Lexend_14 EpdFont declarations elided (see top-of-file note). + EpdFont bitter10Regular(&bitter_10_regular); + EpdFont bitter10Bold(&bitter_10_bold); + EpdFont bitter10Italic(&bitter_10_italic); + EpdFont bitter10BoldItalic(&bitter_10_bolditalic); + EpdFontFamily bitter10Family(&bitter10Regular, &bitter10Bold, + &bitter10Italic, &bitter10BoldItalic); EpdFont bitter12Regular(&bitter_12_regular); EpdFont bitter12Bold(&bitter_12_bold); EpdFont bitter12Italic(&bitter_12_italic); @@ -1659,11 +1788,23 @@ int main(int argc, char** argv) { EpdFont bitter14BoldItalic(&bitter_14_bolditalic); EpdFontFamily bitter14Family(&bitter14Regular, &bitter14Bold, &bitter14Italic, &bitter14BoldItalic); + EpdFont bitter16Regular(&bitter_16_regular); + EpdFont bitter16Bold(&bitter_16_bold); + EpdFont bitter16Italic(&bitter_16_italic); + EpdFont bitter16BoldItalic(&bitter_16_bolditalic); + EpdFontFamily bitter16Family(&bitter16Regular, &bitter16Bold, + &bitter16Italic, &bitter16BoldItalic); GfxRenderer renderer; - // CrumBLE 4.2: Lexend_14 register call elided; see top-of-file rationale. + // CrumBLE 4.4: register every Bitter size the slim build ships (10/12/14/16 + // pt -- 8/9/18/20 are OMIT_*_FONT'd out of tiny-bitter). Without this the + // WASM aborts at the fontId pre-flight when the reader's selected size is + // anything other than 12 or 14, and the JS swallows the failure as an + // orange progress bar with no badge. + renderer.insertFont(BITTER_10_FONT_ID, bitter10Family); renderer.insertFont(BITTER_12_FONT_ID, bitter12Family); renderer.insertFont(BITTER_14_FONT_ID, bitter14Family); + renderer.insertFont(BITTER_16_FONT_ID, bitter16Family); // CrumBLE 4.2: load + register an SD-card .cpfont when the JS caller // supplies one. The font lives at opts.sdFontPath inside MEMFS (the JS @@ -1826,7 +1967,8 @@ int main(int argc, char** argv) { } else { const uint32_t t2 = millis(); const int sectionFails = prebakeSections(epubPath, cacheDir, cacheDirParent, renderer, sectionSettings, - sdFontKeepalive.get(), opts.emitSectionGlyphSubsets); + sdFontKeepalive.get(), opts.emitSectionGlyphSubsets, + opts.atlasBitDepthOverride, opts.sdFontPointSize); const uint32_t dtSections = millis() - t2; if (sectionFails == 0) { LOG_INF("CLI", " sections OK (%u ms)", dtSections); From 7c8431600cd32ffc035ab5b471193fca1585f96a Mon Sep 17 00:00:00 2001 From: Michael Shen Date: Fri, 19 Jun 2026 22:55:35 -0700 Subject: [PATCH 10/10] chore: platformio + websockets patch tweaks for 4.5 Minor build-system updates supporting the 4.5 release. --- platformio.ini | 8 +++++++ scripts/patch_websockets.py | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/platformio.ini b/platformio.ini index daf480fb..04ae7734 100644 --- a/platformio.ini +++ b/platformio.ini @@ -57,6 +57,14 @@ build_flags = -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 -DDISABLE_FS_H_WARNING=1 -DDESTRUCTOR_CLOSES_FILE=1 +; CrumBLE: cap WebSockets per-frame buffer at 4 KB (default is 15 KB). The +; library allocates this much heap on every incoming binary frame, and at the +; default the 15 KB peak was driving MinFree down to ~1.5 KB during book +; uploads on ESP32-C3. The cap must match the browser-side WS_CHUNK_SIZE in +; FilesPage.html (currently 4096) -- frames larger than this are rejected by +; the lib and the connection closes silently mid-upload. Override requires +; patch_websockets.py to add the #ifndef guard around the library's #define. + -DWEBSOCKETS_MAX_DATA_SIZE=4096 ; NimBLE: shrink the runtime to fit alongside WiFi + EPUB parser on ESP32-C3. ; We only ever act as central with one bonded HID remote at a time. -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 diff --git a/scripts/patch_websockets.py b/scripts/patch_websockets.py index 254d0941..b44c530d 100644 --- a/scripts/patch_websockets.py +++ b/scripts/patch_websockets.py @@ -32,6 +32,54 @@ def patch_websockets(env): ) if os.path.isfile(client_cpp): _apply_flush_guard_fix(client_cpp) + ws_header = os.path.join( + libdeps_dir, env_dir, "WebSockets", "src", "WebSockets.h" + ) + if os.path.isfile(ws_header): + _apply_max_data_size_guard(ws_header) + + +def _apply_max_data_size_guard(filepath): + """ + Add an #ifndef guard around the WebSockets.h WEBSOCKETS_MAX_DATA_SIZE + #define so a build flag (-DWEBSOCKETS_MAX_DATA_SIZE=N) can override + the library's hard-coded 15 KB default. + + Lowering this matters on tight-heap targets (ESP32-C3 ~190 KB total) + where the library's per-frame allocation is what crashes MinFree + during large WS file uploads -- the original 15 KB peak left only + ~1.5 KB free at the worst point, which then loses races with + SD-write/WiFi internal allocations. + + Idempotent. + """ + marker = "// CrossPoint patch: allow build-flag override of WEBSOCKETS_MAX_DATA_SIZE" + with open(filepath, "r") as f: + content = f.read() + + if marker in content: + return + + old = "#define WEBSOCKETS_MAX_DATA_SIZE (15 * 1024)" + new = ( + marker + "\n" + "#ifndef WEBSOCKETS_MAX_DATA_SIZE\n" + + old + "\n" + "#endif" + ) + + count = content.count(old) + if count == 0: + print( + "WARNING: WEBSOCKETS_MAX_DATA_SIZE patch target not found in %s " + "- library may have been updated" % filepath + ) + return + + content = content.replace(old, new) + with open(filepath, "w") as f: + f.write(content) + print("Patched WebSockets: WEBSOCKETS_MAX_DATA_SIZE now overridable (%d sites): %s" % (count, filepath)) def _apply_flush_guard_fix(filepath):