Skip to content

Commit 403de13

Browse files
committed
feat(v4.3-rc1): BT page-render stability baseline
Aggregates the v4.3 BT + SD-font reading work into a single revertable baseline. After this commit, BT works with image-heavy and built-in font books; SD-font book BT enable + first-page render works; the known limitation is page-turning on text-heavy SD-font chapters (~28+ elements per page) which crashes due to a per-page deserialize peak exceeding the post-NimBLE contiguous heap budget by a few hundred bytes. Permanent fix is the page-DOM arena (Phase 1 of which is the next planned work). Notable pieces: - Embedded glyph subset: per-section v39 trailer holds the codepoints the chapter actually uses, installed on section load so post-BT glyph lookups hit in-RAM instead of routing through SdCardFont's miss-handler under tight heap. Survives BT enable (Option 1 -- a prior attempt to drop+reload across BT could not reliably re-allocate the 5.6 KB block after NimBLE fragmented the heap). - Page-DOM heap reserve: 18 KB pre-allocated chunk acquired lazily on first chapter open, released for BT enable so NimBLE's ~67 KB allocation has a clean contiguous slot. Best-effort re-acquire after BT disconnect. - TextBlock typography substitutions: curly quotes / dashes / ellipsis / various spaces fold to their ASCII equivalents so embedded subsets built from ASCII-only sources still render correctly. - SdCardFont: static codepoint buffer in prewarm (saves per-render 2 KB alloc), preflight against 8 KB MaxAlloc floor, lazy non-REGULAR styles when BT-bonded controller is present. - BluetoothHIDManager: deinit(true) on disable so stale NimBLEClient objects from the previous session are released. Previously they leaked across enable/disable cycles, causing the next connect's reconnect-with-existing-client path to fail mid-subscribe with BLE_INIT Malloc-failed and a ~22 s recovery. - NimBLE platformio.ini knobs tuned down (MAX_CONNECTIONS=1, bonds=1, MSYS_1_BLOCK_COUNT=4) for the single-gamepad use case. - Page::elements moved from shared_ptr to unique_ptr (eliminates per-element control-block alloc). - Page heap reserve / NimBLE pre-flight gates moved into EpubReaderActivity's BT-toggle path. The page-deserialize crash on heavy pages is intentionally NOT papered over here -- a tried escape valve (drop embedded subset during deserialize) worked mechanically but broke glyph rendering because the lazy reinstall could not allocate. Documented inline in EpubReaderActivity render() so the next attempt has the failure mode written down.
1 parent bfcf04c commit 403de13

19 files changed

Lines changed: 675 additions & 74 deletions

File tree

lib/EpdFont/EpdFontFamily.cpp

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,71 @@ EpdFontFamily::GlyphData EpdFontFamily::getGlyphData(const uint32_t cp, const St
262262
return {font->data, glyph};
263263
}
264264

265+
// CrumBLE 4.3: regular-style fallback for SD fonts with missing styles.
266+
// When this is a non-regular style and the lookup fully failed (no
267+
// embedded subset entry + miss handler returned null -- typically
268+
// because the SD font doesn't have this style at all, e.g. Readerly_12
269+
// ships only REGULAR), fall back to the regular glyph instead of
270+
// REPLACEMENT_GLYPH. The result is italic/bold text rendered in the
271+
// regular style -- visually a small downgrade but readable. Previously
272+
// these codepoints became '?' which broke ~30% of text in books with
273+
// mixed-style italic phrases. We DON'T retry through the regular style's
274+
// miss handler -- recurse via getGlyphData(cp, REGULAR) which does the
275+
// full embedded + miss-handler lookup on the regular style.
276+
if (style != REGULAR) {
277+
if (const GlyphData regularData = getGlyphData(cp, REGULAR); regularData.glyph) {
278+
return regularData;
279+
}
280+
}
281+
282+
// CrumBLE 4.3: typography substitutions. Some SD fonts (Readerly_12,
283+
// ChareInk_*) don't ship Unicode punctuation glyphs -- curly quotes,
284+
// dashes, ellipsis etc. EPUBs use these heavily (smart-quoted dialogue,
285+
// em-dashed asides). Without substitution they render as REPLACEMENT
286+
// GLYPH ('?'), which dominates the page on dialogue-heavy chapters.
287+
// ASCII equivalents are guaranteed to be in any font (a-z + ASCII
288+
// punctuation), so we map the smart character to its straight equivalent
289+
// and retry the lookup. Trade-off: typography downgrade (curly quotes
290+
// become straight, em dashes become hyphens) but the text is readable.
291+
uint32_t substitute = 0;
292+
switch (cp) {
293+
case 0x2018: // LEFT SINGLE QUOTATION MARK
294+
case 0x2019: // RIGHT SINGLE QUOTATION MARK
295+
case 0x201A: // SINGLE LOW-9 QUOTATION MARK
296+
case 0x201B: // SINGLE HIGH-REVERSED-9 QUOTATION MARK
297+
substitute = 0x0027; // APOSTROPHE
298+
break;
299+
case 0x201C: // LEFT DOUBLE QUOTATION MARK
300+
case 0x201D: // RIGHT DOUBLE QUOTATION MARK
301+
case 0x201E: // DOUBLE LOW-9 QUOTATION MARK
302+
case 0x201F: // DOUBLE HIGH-REVERSED-9 QUOTATION MARK
303+
substitute = 0x0022; // QUOTATION MARK
304+
break;
305+
case 0x2013: // EN DASH
306+
case 0x2014: // EM DASH
307+
case 0x2015: // HORIZONTAL BAR
308+
case 0x2212: // MINUS SIGN
309+
substitute = 0x002D; // HYPHEN-MINUS
310+
break;
311+
case 0x2026: // HORIZONTAL ELLIPSIS
312+
substitute = 0x002E; // FULL STOP (one dot -- not "..." since the
313+
// page-DOM wordXpos accounts for a single glyph)
314+
break;
315+
case 0x00A0: // NO-BREAK SPACE
316+
case 0x2009: // THIN SPACE
317+
case 0x200A: // HAIR SPACE
318+
case 0x202F: // NARROW NO-BREAK SPACE
319+
substitute = 0x0020; // SPACE
320+
break;
321+
default:
322+
break;
323+
}
324+
if (substitute != 0 && substitute != cp) {
325+
if (const GlyphData subData = getGlyphData(substitute, style); subData.glyph) {
326+
return subData;
327+
}
328+
}
329+
265330
if (cp != REPLACEMENT_GLYPH) {
266331
return getGlyphData(REPLACEMENT_GLYPH, style);
267332
}

lib/EpdFont/SdCardFont.cpp

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -668,12 +668,18 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn
668668
// = ~131K comparisons, but in practice pages contain far fewer unique codepoints so the
669669
// actual cost is much lower. This is dwarfed by SD I/O that follows. Alternatives (hash
670670
// set, bitmap) exceed the 256-byte stack limit or add template bloat.
671-
// Heap-allocated: MAX_PAGE_GLYPHS * 4 = 2048 bytes, too large for stack (limit < 256 bytes)
672-
std::unique_ptr<uint32_t[]> codepoints(new (std::nothrow) uint32_t[MAX_PAGE_GLYPHS]);
673-
if (!codepoints) {
674-
LOG_ERR("SDCF", "Failed to allocate codepoint buffer (%u bytes)", MAX_PAGE_GLYPHS * 4);
675-
return -1;
676-
}
671+
//
672+
// CrumBLE 4.3 task #2 follow-up: this buffer is now STATIC (one-time
673+
// 2 KB BSS allocation at program start, reused every render). The
674+
// previous per-render heap allocation was the operator new[] that
675+
// throws bad_alloc post-NimBLE, which terminates the render task on
676+
// SD-font + BT. Moving the buffer to BSS removes the per-render heap
677+
// pressure entirely. Safety: prewarm() is only called from the render
678+
// task (FontCacheManager::PrewarmScope::endScanAndPrewarm); the task
679+
// is single-threaded so there's no reentrancy concern. Two SdCardFont
680+
// instances cannot prewarm concurrently because the render task is
681+
// the sole caller.
682+
static uint32_t codepoints[MAX_PAGE_GLYPHS];
677683
uint32_t cpCount = 0;
678684

679685
const unsigned char* p = reinterpret_cast<const unsigned char*>(utf8Text);
@@ -747,13 +753,13 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn
747753
}
748754

749755
// Sort codepoints for ordered interval building
750-
std::sort(codepoints.get(), codepoints.get() + cpCount);
756+
std::sort(codepoints, codepoints + cpCount);
751757

752758
// Prewarm each requested style
753759
int totalMissed = 0;
754760
for (uint8_t si = 0; si < MAX_STYLES; si++) {
755761
if (!(styleMask & (1 << si)) || !styles_[si].present) continue;
756-
totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly);
762+
totalMissed += prewarmStyle(si, codepoints, cpCount, metadataOnly);
757763
}
758764

759765
stats_.prewarmTotalMs = millis() - startMs;
@@ -1467,3 +1473,42 @@ bool SdCardFont::miniIs2Bit(uint8_t styleIdx) const {
14671473
if (styleIdx >= kMaxStylesForAccessor) return false;
14681474
return styles_[styleIdx].miniData.is2Bit;
14691475
}
1476+
uint16_t SdCardFont::miniKernLeftEntryCount(uint8_t styleIdx) const {
1477+
if (styleIdx >= kMaxStylesForAccessor) return 0;
1478+
return styles_[styleIdx].miniKernLeftEntryCount;
1479+
}
1480+
uint16_t SdCardFont::miniKernRightEntryCount(uint8_t styleIdx) const {
1481+
if (styleIdx >= kMaxStylesForAccessor) return 0;
1482+
return styles_[styleIdx].miniKernRightEntryCount;
1483+
}
1484+
uint8_t SdCardFont::miniKernLeftClassCount(uint8_t styleIdx) const {
1485+
if (styleIdx >= kMaxStylesForAccessor) return 0;
1486+
return styles_[styleIdx].miniKernLeftClassCount;
1487+
}
1488+
uint8_t SdCardFont::miniKernRightClassCount(uint8_t styleIdx) const {
1489+
if (styleIdx >= kMaxStylesForAccessor) return 0;
1490+
return styles_[styleIdx].miniKernRightClassCount;
1491+
}
1492+
uint16_t SdCardFont::miniLigaturePairCount(uint8_t styleIdx) const {
1493+
// CrumBLE 4.3 v2: ligature pairs are FONT-WIDE (header), not per-style.
1494+
// Returned once for any installed style so the emit code can write the
1495+
// same ligature table per style slot.
1496+
if (styleIdx >= kMaxStylesForAccessor) return 0;
1497+
return styles_[styleIdx].header.ligaturePairCount;
1498+
}
1499+
const EpdKernClassEntry* SdCardFont::miniKernLeftClassesPtr(uint8_t styleIdx) const {
1500+
if (styleIdx >= kMaxStylesForAccessor) return nullptr;
1501+
return styles_[styleIdx].miniKernLeftClasses;
1502+
}
1503+
const EpdKernClassEntry* SdCardFont::miniKernRightClassesPtr(uint8_t styleIdx) const {
1504+
if (styleIdx >= kMaxStylesForAccessor) return nullptr;
1505+
return styles_[styleIdx].miniKernRightClasses;
1506+
}
1507+
const int8_t* SdCardFont::miniKernMatrixPtr(uint8_t styleIdx) const {
1508+
if (styleIdx >= kMaxStylesForAccessor) return nullptr;
1509+
return styles_[styleIdx].miniKernMatrix;
1510+
}
1511+
const EpdLigaturePair* SdCardFont::miniLigaturePairsPtr(uint8_t styleIdx) const {
1512+
if (styleIdx >= kMaxStylesForAccessor) return nullptr;
1513+
return styles_[styleIdx].ligaturePairs;
1514+
}

lib/EpdFont/SdCardFont.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ class SdCardFont {
136136
int16_t miniAscender(uint8_t styleIdx) const;
137137
int16_t miniDescender(uint8_t styleIdx) const;
138138
bool miniIs2Bit(uint8_t styleIdx) const;
139+
// CrumBLE 4.3 v2: kerning + ligature accessors for the embedded subset's
140+
// v2 per-style payload (prebake CLI consumes these to write the kern
141+
// matrix / class tables / ligature pairs alongside the bitmap blob).
142+
uint16_t miniKernLeftEntryCount(uint8_t styleIdx) const;
143+
uint16_t miniKernRightEntryCount(uint8_t styleIdx) const;
144+
uint8_t miniKernLeftClassCount(uint8_t styleIdx) const;
145+
uint8_t miniKernRightClassCount(uint8_t styleIdx) const;
146+
uint16_t miniLigaturePairCount(uint8_t styleIdx) const;
147+
const EpdKernClassEntry* miniKernLeftClassesPtr(uint8_t styleIdx) const;
148+
const EpdKernClassEntry* miniKernRightClassesPtr(uint8_t styleIdx) const;
149+
const int8_t* miniKernMatrixPtr(uint8_t styleIdx) const;
150+
const EpdLigaturePair* miniLigaturePairsPtr(uint8_t styleIdx) const;
139151

140152
private:
141153
// Per-style metadata (parsed from file header/TOC)

lib/Epub/Epub/EmbeddedGlyphSubset.h

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@
4444
// .cpfont's bitmap blob). The prebake CLI rebases them when emitting; the
4545
// loader doesn't need to translate further.
4646
//
47-
// Kerning + ligatures are NOT yet embedded in v1 of this block; they would
48-
// add ~kernLeftEntries*3 + kernRightEntries*3 + leftClasses*rightClasses +
49-
// ligaturePairs*8 bytes per style (~5-15 KB on typical fonts). For the
50-
// vertical-slice scope, kerning falls back to "no kerning applied" when an
51-
// embedded subset is active. A future v2 of this block can add them
52-
// without disturbing v1 readers (extend Style header with optional offsets
53-
// to kern + ligature blobs).
47+
// v2 (CrumBLE 4.3 second pass): kerning + ligatures are now embedded.
48+
// Per-style header gained 8 bytes of count fields and the per-style data
49+
// blob is followed by kernLeftEntries, kernRightEntries, kernMatrix, and
50+
// ligaturePairs in that order. Adds ~700 bytes-2 KB per style depending
51+
// on font density. Fixes the "text overshoots viewport / Outside range
52+
// (480, *)" floods that happened on SD-font + BT books because the
53+
// runtime was using glyph-only advance widths without the pair-kerning
54+
// offsets the prebake CLI baked into wordXpos. v1 (kerning-less) sections
55+
// are rejected by the v2 loader -- bump triggered a full re-bake of all
56+
// section caches anyway, so callers must regenerate prebake artifacts.
5457

5558
#include <cstdint>
5659

@@ -59,7 +62,7 @@
5962
namespace embeddedGlyphSubset {
6063

6164
constexpr uint32_t BLOCK_MAGIC = 0x4253474C; // "LGSB" little-endian
62-
constexpr uint16_t BLOCK_VERSION = 1;
65+
constexpr uint16_t BLOCK_VERSION = 2;
6366
constexpr uint8_t MAX_STYLES = 4;
6467

6568
// Per-style flag bits.
@@ -76,8 +79,10 @@ struct BlockHeader {
7679
} __attribute__((packed));
7780
static_assert(sizeof(BlockHeader) == 16, "EmbeddedGlyphSubset::BlockHeader must be 16 bytes");
7881

79-
// On-disk per-style entry header (24 bytes). Followed by intervals, glyphs,
80-
// and bitmaps in that order.
82+
// On-disk per-style entry header (32 bytes). Followed by intervals, glyphs,
83+
// bitmaps, kernLeftEntries, kernRightEntries, kernMatrix, ligaturePairs in
84+
// that order. The four kern* and ligature* count fields drive the size of
85+
// each blob; install code reads them and resizes the matching slot vectors.
8186
struct StyleHeader {
8287
uint8_t styleId;
8388
uint8_t flags;
@@ -89,7 +94,14 @@ struct StyleHeader {
8994
int16_t ascender;
9095
int16_t descender;
9196
uint16_t reserved2;
97+
// v2 additions: kerning + ligature counts. Blobs of corresponding sizes
98+
// come after the bitmap, in the listed order.
99+
uint16_t kernLeftEntryCount;
100+
uint16_t kernRightEntryCount;
101+
uint8_t kernLeftClassCount;
102+
uint8_t kernRightClassCount;
103+
uint16_t ligaturePairCount;
92104
} __attribute__((packed));
93-
static_assert(sizeof(StyleHeader) == 24, "EmbeddedGlyphSubset::StyleHeader must be 24 bytes");
105+
static_assert(sizeof(StyleHeader) == 32, "EmbeddedGlyphSubset::StyleHeader v2 must be 32 bytes");
94106

95107
} // namespace embeddedGlyphSubset

lib/Epub/Epub/Page.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ static_assert(TableFragmentRow::MAX_SERIALIZED_CELLS == MAX_TABLE_CELLS_PER_ROW)
1515
static_assert(PageTableFragment::MAX_SERIALIZED_ROWS == MAX_TABLE_ROWS_PER_FRAGMENT);
1616

1717
template <typename Predicate>
18-
void renderFilteredPageElements(const std::vector<std::shared_ptr<PageElement>>& elements, GfxRenderer& renderer,
18+
void renderFilteredPageElements(const std::vector<std::unique_ptr<PageElement>>& elements, GfxRenderer& renderer,
1919
const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) {
2020
for (const auto& element : elements) {
2121
if (predicate(*element)) {

lib/Epub/Epub/Page.h

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,13 @@ class PageTableFragment final : public PageElement {
120120

121121
class Page {
122122
public:
123-
// the list of block index and line numbers on this page
124-
std::vector<std::shared_ptr<PageElement>> elements;
123+
// the list of block index and line numbers on this page.
124+
// CrumBLE 4.3: unique_ptr (was shared_ptr) to skip the per-element
125+
// control-block heap allocation that bad_alloc terminates the device
126+
// on SD-font + BT (each PageLine push_back used to allocate ~24 bytes
127+
// for the shared_ptr control block; under post-NimBLE heap fragmentation
128+
// that allocation can't find a contiguous slot).
129+
std::vector<std::unique_ptr<PageElement>> elements;
125130
std::vector<FootnoteEntry> footnotes;
126131
static constexpr uint16_t MAX_FOOTNOTES_PER_PAGE = 16;
127132

@@ -144,7 +149,7 @@ class Page {
144149
// Check if page contains any images (used to force full refresh)
145150
bool hasImages() const {
146151
return std::any_of(elements.begin(), elements.end(),
147-
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
152+
[](const std::unique_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
148153
}
149154

150155
// Get bounding box of all images on the page (union of image rects)

0 commit comments

Comments
 (0)