diff --git a/README.md b/README.md index 1d7ebad0..2bd43d1b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,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. @@ -142,6 +144,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/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/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/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/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 87c24120..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 @@ -1625,6 +1629,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); @@ -2082,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/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/lib/hal/BluetoothHIDManager.cpp b/lib/hal/BluetoothHIDManager.cpp index d5b253f3..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); } @@ -485,26 +520,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 = @@ -735,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; @@ -744,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/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/platformio.ini b/platformio.ini index c0b106fa..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 @@ -68,14 +76,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 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): 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/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/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/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/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/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/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/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/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/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/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/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 153136cf..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) { @@ -891,14 +898,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/, @@ -1018,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(); @@ -1093,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 @@ -1355,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 @@ -1634,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(); + } }); } @@ -1969,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(); @@ -1986,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); @@ -2000,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 @@ -2082,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(); @@ -2187,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); @@ -2200,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 @@ -2632,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; { @@ -2666,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` @@ -2676,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: { @@ -2688,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; @@ -2970,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; @@ -3278,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(); @@ -3555,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 @@ -3566,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); @@ -3632,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; @@ -3646,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; @@ -3723,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; @@ -3746,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) { @@ -3758,15 +4100,36 @@ 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(); - 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; @@ -3896,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 @@ -4031,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); @@ -4084,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); } @@ -4098,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); } @@ -4123,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(); } @@ -4191,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) { @@ -4328,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 2bffa253..da034ee0 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -148,19 +148,47 @@ 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. + // + // 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(); + // 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. 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 + // 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}); @@ -184,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/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/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/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/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/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(); }; 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; } 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/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; } }; 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(), 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/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); diff --git a/tools/crumble-prebake/src/main.cpp b/tools/crumble-prebake/src/main.cpp index 4eaeab9c..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 @@ -872,7 +898,30 @@ 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; + bool anyAtlasEmitted = false; for (int spineIdx = 0; spineIdx < spineCount; ++spineIdx) { Section section(epub, spineIdx, renderer); bool imagesWereSuppressed = false; @@ -920,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); } } } @@ -1050,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); @@ -1105,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; @@ -1121,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) { @@ -1160,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. @@ -1198,8 +1333,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, 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 @@ -1208,9 +1349,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; @@ -1225,14 +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 outByteIdx = y * outRowBytes + (x / 8u); - const uint32_t outBitInByte = x % 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)); + } } } } @@ -1266,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; @@ -1359,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) { @@ -1615,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); @@ -1627,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 @@ -1794,14 +1967,20 @@ 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); } 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; } } }