From 65bcadc91a9e6130d4cedca559c8bc2b7b559ee1 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 10:00:22 +0200 Subject: [PATCH 01/13] Integrate upstream #1392 by adriancaruana --- .gitignore | 1 + lib/EpdFont/EpdFont.cpp | 38 +- lib/EpdFont/EpdFontData.h | 12 + lib/EpdFont/FontDecompressor.cpp | 37 + lib/EpdFont/SdCardFont.cpp | 1014 ++++++++++++++++++ lib/EpdFont/SdCardFont.h | 187 ++++ lib/EpdFont/SdCardFontManager.cpp | 104 ++ lib/EpdFont/SdCardFontManager.h | 50 + lib/EpdFont/SdCardFontRegistry.cpp | 172 +++ lib/EpdFont/SdCardFontRegistry.h | 42 + lib/EpdFont/scripts/fontconvert_sdcard.py | 882 +++++++++++++++ lib/EpdFont/scripts/generate-sd-fonts.sh | 85 ++ lib/Epub/Epub/ParsedText.cpp | 19 + lib/GfxRenderer/FontCacheManager.cpp | 25 +- lib/GfxRenderer/FontCacheManager.h | 4 +- lib/GfxRenderer/GfxRenderer.cpp | 33 +- lib/GfxRenderer/GfxRenderer.h | 15 + src/CrossPointSettings.cpp | 55 +- src/CrossPointSettings.h | 10 +- src/JsonSettingsIO.cpp | 15 + src/SdCardFontGlobals.h | 38 + src/SdCardFontSystem.cpp | 160 +++ src/SdCardFontSystem.h | 32 + src/SettingsList.h | 12 +- src/activities/ActivityManager.cpp | 3 + src/activities/reader/EpubReaderActivity.cpp | 57 +- src/activities/settings/SettingsActivity.cpp | 27 +- src/main.cpp | 13 +- src/network/CrossPointWebServer.cpp | 24 +- 29 files changed, 3086 insertions(+), 80 deletions(-) create mode 100644 lib/EpdFont/SdCardFont.cpp create mode 100644 lib/EpdFont/SdCardFont.h create mode 100644 lib/EpdFont/SdCardFontManager.cpp create mode 100644 lib/EpdFont/SdCardFontManager.h create mode 100644 lib/EpdFont/SdCardFontRegistry.cpp create mode 100644 lib/EpdFont/SdCardFontRegistry.h create mode 100644 lib/EpdFont/scripts/fontconvert_sdcard.py create mode 100755 lib/EpdFont/scripts/generate-sd-fonts.sh create mode 100644 src/SdCardFontGlobals.h create mode 100644 src/SdCardFontSystem.cpp create mode 100644 src/SdCardFontSystem.h diff --git a/.gitignore b/.gitignore index f4bbcdfce5..17736de5fe 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ build .history/ /.venv *.local* +*.cpfont diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index fd834fa957..6d76e4a4cc 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -148,24 +148,32 @@ uint32_t EpdFont::applyLigatures(uint32_t cp, const char*& text) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const int count = data->intervalCount; - if (count == 0) return nullptr; - - const EpdUnicodeInterval* intervals = data->intervals; - const auto* end = intervals + count; - - // upper_bound: range lookup. Finds the first interval with first > cp, so the - // interval just before it is the last one with first <= cp. That's the only - // candidate that could contain cp. Then we verify cp <= candidate.last. - const auto it = std::upper_bound( - intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); - - if (it != intervals) { - const auto& interval = *(it - 1); - if (cp <= interval.last) { - return &data->glyph[interval.offset + (cp - interval.first)]; + if (count == 0 && !data->glyphMissHandler) return nullptr; + + if (count > 0) { + const EpdUnicodeInterval* intervals = data->intervals; + const auto* end = intervals + count; + + // upper_bound: range lookup. Finds the first interval with first > cp, so the + // interval just before it is the last one with first <= cp. That's the only + // candidate that could contain cp. Then we verify cp <= candidate.last. + const auto it = std::upper_bound( + intervals, end, cp, [](uint32_t value, const EpdUnicodeInterval& interval) { return value < interval.first; }); + + if (it != intervals) { + const auto& interval = *(it - 1); + if (cp <= interval.last) { + return &data->glyph[interval.offset + (cp - interval.first)]; + } } } + // Codepoint not in interval table — try on-demand loading (SD card fonts). + if (data->glyphMissHandler) { + const EpdGlyph* loaded = data->glyphMissHandler(data->glyphMissCtx, cp); + if (loaded) return loaded; + } + if (cp != REPLACEMENT_GLYPH) { return getGlyph(REPLACEMENT_GLYPH); } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 380c5733dc..cb1d5250a0 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -129,4 +129,16 @@ typedef struct { uint8_t kernRightClassCount; ///< Number of distinct right classes (matrix cols) const EpdLigaturePair* ligaturePairs; ///< Sorted ligature pair table (nullptr if none) uint32_t ligaturePairCount; ///< Number of entries in ligaturePairs + + /// On-demand glyph loading for fonts that don't keep all glyphs in RAM (e.g. SD card fonts). + /// Called by getGlyph() when a codepoint is not found in the interval table. + /// Returns a valid EpdGlyph* with correct metadata, or nullptr to fall back to the + /// replacement glyph. The returned pointer is valid until the next glyphMissHandler + /// call that causes a ring-buffer eviction — callers must consume it (measure or draw) + /// before requesting another missed glyph. + const EpdGlyph* (*glyphMissHandler)(void* ctx, uint32_t codepoint); + + /// Context pointer for glyphMissHandler (typically SdCardFont*). Also used by + /// GfxRenderer::getGlyphBitmap() to retrieve overflow bitmaps via SdCardFont. + void* glyphMissCtx; } EpdFontData; diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index a015315553..e95883ab99 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -258,6 +258,43 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 } } + // Add ligature output glyphs: if both input codepoints of a ligature pair are + // in the needed set, the output glyph will be queried during rendering. + // Must run BEFORE the neededGlyphGroups[] parallel-array loop below so appended + // glyphs receive a group index — otherwise hot-group lookup misses them. + if (fontData->ligaturePairs && fontData->ligaturePairCount > 0) { + for (uint32_t li = 0; li < fontData->ligaturePairCount && glyphCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = fontData->ligaturePairs[li].pair >> 16; + uint32_t rightCp = fontData->ligaturePairs[li].pair & 0xFFFF; + + int32_t leftIdx = findGlyphIndex(fontData, leftCp); + int32_t rightIdx = findGlyphIndex(fontData, rightCp); + if (leftIdx < 0 || rightIdx < 0) continue; + + bool hasLeft = false, hasRight = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(leftIdx)) hasLeft = true; + if (neededGlyphs[i] == static_cast(rightIdx)) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + int32_t outIdx = findGlyphIndex(fontData, fontData->ligaturePairs[li].ligatureCp); + if (outIdx < 0) continue; + + bool found = false; + for (uint16_t i = 0; i < glyphCount; i++) { + if (neededGlyphs[i] == static_cast(outIdx)) { + found = true; + break; + } + } + if (!found) { + neededGlyphs[glyphCount++] = static_cast(outIdx); + } + } + } + if (glyphCount == 0) return 0; // Step 2: Compute total buffer size and collect unique groups diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp new file mode 100644 index 0000000000..53a20fdf8f --- /dev/null +++ b/lib/EpdFont/SdCardFont.cpp @@ -0,0 +1,1014 @@ +#include "SdCardFont.h" + +#include +#include +#include + +#include +#include +#include +#include + +static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdKernClassEntry) == 3, "EpdKernClassEntry must be 3 bytes to match .cpfont file layout"); +static_assert(sizeof(EpdLigaturePair) == 8, "EpdLigaturePair must be 8 bytes to match .cpfont file layout"); + +// FNV-1a hash for content-based font ID generation +static constexpr uint32_t FNV_OFFSET = 2166136261u; +static constexpr uint32_t FNV_PRIME = 16777619u; + +static uint32_t fnv1a(const uint8_t* data, size_t len, uint32_t hash = FNV_OFFSET) { + for (size_t i = 0; i < len; i++) { + hash ^= data[i]; + hash *= FNV_PRIME; + } + return hash; +} + +// .cpfont magic bytes +static constexpr char CPFONT_MAGIC[8] = {'C', 'P', 'F', 'O', 'N', 'T', '\0', '\0'}; +static constexpr uint16_t CPFONT_VERSION = 4; +static constexpr uint32_t HEADER_SIZE = 32; +static constexpr uint32_t STYLE_TOC_ENTRY_SIZE = 32; + +// Helper to read little-endian values from byte buffer +static inline uint16_t readU16(const uint8_t* p) { return p[0] | (p[1] << 8); } +static inline int16_t readI16(const uint8_t* p) { return static_cast(p[0] | (p[1] << 8)); } +static inline uint32_t readU32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } + +SdCardFont::~SdCardFont() { freeAll(); } + +// --- Per-style free/cleanup --- + +void SdCardFont::freeStyleMiniData(PerStyle& s) { + delete[] s.miniIntervals; + s.miniIntervals = nullptr; + delete[] s.miniGlyphs; + s.miniGlyphs = nullptr; + delete[] s.miniBitmap; + s.miniBitmap = nullptr; + s.miniIntervalCount = 0; + s.miniGlyphCount = 0; + freeStyleMiniKern(s); + memset(&s.miniData, 0, sizeof(s.miniData)); + s.epdFont.data = &s.stubData; +} + +void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { + delete[] s.kernLeftClasses; + s.kernLeftClasses = nullptr; + delete[] s.kernRightClasses; + s.kernRightClasses = nullptr; + delete[] s.ligaturePairs; + s.ligaturePairs = nullptr; + s.kernLigLoaded = false; +} + +void SdCardFont::freeStyleMiniKern(PerStyle& s) { + delete[] s.miniKernLeftClasses; + s.miniKernLeftClasses = nullptr; + delete[] s.miniKernRightClasses; + s.miniKernRightClasses = nullptr; + delete[] s.miniKernMatrix; + s.miniKernMatrix = nullptr; + s.miniKernLeftEntryCount = 0; + s.miniKernRightEntryCount = 0; + s.miniKernLeftClassCount = 0; + s.miniKernRightClassCount = 0; +} + +void SdCardFont::freeStyleAll(PerStyle& s) { + freeStyleMiniData(s); + delete[] s.fullIntervals; + s.fullIntervals = nullptr; + freeStyleKernLigatureData(s); + s.present = false; +} + +// --- Global free/cleanup --- + +void SdCardFont::freeAll() { + clearOverflow(); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + freeStyleAll(styles_[i]); + } + styleCount_ = 0; + contentHash_ = 0; + loaded_ = false; +} + +void SdCardFont::clearOverflow() { + for (uint32_t i = 0; i < overflowCount_; i++) { + delete[] overflow_[i].bitmap; + overflow_[i].bitmap = nullptr; + overflow_[i].codepoint = 0; + } + overflowCount_ = 0; + overflowNext_ = 0; +} + +// --- Per-style kern/ligature --- + +void SdCardFont::applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const { + // Kern data uses the per-page mini tables (renumbered class IDs). The full + // kern matrix is never resident — see PerStyle::miniKernMatrix comment. + data.kernLeftClasses = s.miniKernLeftClasses; + data.kernRightClasses = s.miniKernRightClasses; + data.kernMatrix = s.miniKernMatrix; + data.kernLeftEntryCount = s.miniKernLeftEntryCount; + data.kernRightEntryCount = s.miniKernRightEntryCount; + data.kernLeftClassCount = s.miniKernLeftClassCount; + data.kernRightClassCount = s.miniKernRightClassCount; + // Ligatures are small (typically < 1KB) so they stay resident. + data.ligaturePairs = s.ligaturePairs; + data.ligaturePairCount = s.header.ligaturePairCount; +} + +bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { + if (s.kernLigLoaded) return true; + bool hasKern = s.header.kernLeftEntryCount > 0; + bool hasLig = s.header.ligaturePairCount > 0; + if (!hasKern && !hasLig) { + s.kernLigLoaded = true; + return true; + } + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for kern/lig: %s", filePath_); + return false; + } + + if (hasKern) { + // Load only the small class-lookup tables (~3KB each). The full matrix + // (~36KB contiguous for Literata) is built per-page from SD in + // buildMiniKernMatrix(). + s.kernLeftClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; + s.kernRightClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; + + if (!s.kernLeftClasses || !s.kernRightClasses) { + LOG_ERR("SDCF", "Failed to allocate kern classes (%u+%u bytes)", s.header.kernLeftEntryCount * 3u, + s.header.kernRightEntryCount * 3u); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + + if (!file.seekSet(s.kernLeftFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to kern data"); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + size_t leftSz = s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + size_t rightSz = s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + if (file.read(reinterpret_cast(s.kernLeftClasses), leftSz) != static_cast(leftSz) || + file.read(reinterpret_cast(s.kernRightClasses), rightSz) != static_cast(rightSz)) { + LOG_ERR("SDCF", "Failed to read kern classes"); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + } + + if (hasLig) { + s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; + if (!s.ligaturePairs) { + LOG_ERR("SDCF", "Failed to allocate ligature pairs"); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + if (!file.seekSet(s.ligatureFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to ligature data"); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + size_t sz = s.header.ligaturePairCount * sizeof(EpdLigaturePair); + if (file.read(reinterpret_cast(s.ligaturePairs), sz) != static_cast(sz)) { + LOG_ERR("SDCF", "Failed to read ligature pairs"); + freeStyleKernLigatureData(s); + file.close(); + return false; + } + } + + file.close(); + s.kernLigLoaded = true; + + // Make ligatures visible to the stub (used when no mini data built yet). + // Kern stays nullptr on the stub — it is only wired in miniData via + // applyKernLigaturePointers() after buildMiniKernMatrix() runs. + s.stubData.ligaturePairs = s.ligaturePairs; + s.stubData.ligaturePairCount = s.header.ligaturePairCount; + + LOG_DBG("SDCF", "Kern classes + lig loaded: kernL=%u, kernR=%u, ligs=%u", s.header.kernLeftEntryCount, + s.header.kernRightEntryCount, s.header.ligaturePairCount); + return true; +} + +// --- Per-page mini kern matrix --- + +// Local copy of EpdFont.cpp's lookupKernClass (that one is file-static there). +// Returns the 1-based class ID for `cp`, or 0 if the codepoint has no kerning class. +static uint8_t miniLookupKernClass(const EpdKernClassEntry* entries, uint16_t count, uint32_t cp) { + if (!entries || count == 0 || cp > 0xFFFF) return 0; + const auto target = static_cast(cp); + const auto* end = entries + count; + const auto it = + std::lower_bound(entries, end, target, [](const EpdKernClassEntry& e, uint16_t v) { return e.codepoint < v; }); + return (it != end && it->codepoint == target) ? it->classId : 0; +} + +// Build a small per-page kern matrix containing ONLY the (leftClass, rightClass) +// pairs reachable from codepoints in the current text. Class IDs are renumbered +// to a dense 1..N range so the resulting matrix is usedLeft × usedRight (typical +// Latin page: ~25×25 bytes) instead of the font's full ~180×200 (~36KB). +// +// Correctness: EpdFont::getKerning only touches `kernLeftClasses` / +// `kernRightClasses` / `kernMatrix` / the count fields — we swap all of them to +// the mini versions together in applyKernLigaturePointers, so a codepoint not +// on this page simply returns class 0 (no kerning), which was the pre-existing +// behavior for any codepoint outside the kern classes. +bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount) { + freeStyleMiniKern(s); + if (!s.kernLeftClasses || !s.kernRightClasses || s.header.kernLeftEntryCount == 0 || + s.header.kernRightEntryCount == 0) { + return true; // font has no kern classes — nothing to build + } + + // Step 1: mark used left/right classes via a 256-wide bitmap (class IDs are uint8_t). + bool usedLeft[256] = {}; + bool usedRight[256] = {}; + for (uint32_t i = 0; i < cpCount; i++) { + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]); + if (lc) usedLeft[lc] = true; + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]); + if (rc) usedRight[rc] = true; + } + + // Step 2: build renumber maps (oldClassId -> newClassId, 1-based) and + // reverse maps (newClassId -> oldClassId) for the SD read step. + uint8_t leftRenumber[256] = {}; + uint8_t rightRenumber[256] = {}; + uint8_t newToOldLeft[256] = {}; + uint8_t newToOldRight[256] = {}; + uint8_t numLeft = 0, numRight = 0; + for (int i = 1; i < 256; i++) { + if (usedLeft[i]) { + numLeft++; + leftRenumber[i] = numLeft; + newToOldLeft[numLeft] = static_cast(i); + } + if (usedRight[i]) { + numRight++; + rightRenumber[i] = numRight; + newToOldRight[numRight] = static_cast(i); + } + } + if (numLeft == 0 || numRight == 0) { + return true; // no kern pairs applicable on this page + } + + // Step 3: count how many codepoint→classId entries the mini class tables need. + // Each resident class table has one entry per kerned codepoint in the page. + uint16_t miniLeftCount = 0; + uint16_t miniRightCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + if (miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, codepoints[i]) != 0) miniLeftCount++; + if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++; + } + + // Step 4: allocate the three mini buffers. The matrix is <1KB in practice + // (<30 × <30 × 1 byte) so fragmentation is a non-issue. + const uint32_t matrixBytes = static_cast(numLeft) * numRight; + s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount]; + s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount]; + s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes]; + if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) { + LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u, + matrixBytes); + freeStyleMiniKern(s); + return false; + } + + // Step 5: populate mini class tables. `codepoints` is already sorted (see + // prewarm()) so the output is sorted by codepoint — required for binary + // search in lookupKernClass during render. + uint16_t lIdx = 0, rIdx = 0; + for (uint32_t i = 0; i < cpCount; i++) { + uint32_t cp = codepoints[i]; + if (cp > 0xFFFF) continue; // kern class entries are uint16_t + uint8_t lc = miniLookupKernClass(s.kernLeftClasses, s.header.kernLeftEntryCount, cp); + if (lc) { + s.miniKernLeftClasses[lIdx].codepoint = static_cast(cp); + s.miniKernLeftClasses[lIdx].classId = leftRenumber[lc]; + lIdx++; + } + uint8_t rc = miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, cp); + if (rc) { + s.miniKernRightClasses[rIdx].codepoint = static_cast(cp); + s.miniKernRightClasses[rIdx].classId = rightRenumber[rc]; + rIdx++; + } + } + + // Step 6: read the full matrix's rows for each used left class, keep only + // columns for used right classes. One SD seek + one read per used left class; + // a row is kernRightClassCount bytes (~200 for Literata). + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont for mini kern: %s", filePath_); + freeStyleMiniKern(s); + return false; + } + + std::unique_ptr rowBuf(new (std::nothrow) int8_t[s.header.kernRightClassCount]); + if (!rowBuf) { + LOG_ERR("SDCF", "Failed to allocate row buffer (%u bytes)", s.header.kernRightClassCount); + file.close(); + freeStyleMiniKern(s); + return false; + } + + for (uint8_t newL = 1; newL <= numLeft; newL++) { + const uint8_t oldL = newToOldLeft[newL]; + const uint32_t rowFileOff = s.kernMatrixFileOffset + (oldL - 1u) * s.header.kernRightClassCount; + if (!file.seekSet(rowFileOff)) { + LOG_ERR("SDCF", "Failed to seek to kern row %u", oldL); + file.close(); + freeStyleMiniKern(s); + return false; + } + if (file.read(reinterpret_cast(rowBuf.get()), s.header.kernRightClassCount) != + static_cast(s.header.kernRightClassCount)) { + LOG_ERR("SDCF", "Failed to read kern row %u", oldL); + file.close(); + freeStyleMiniKern(s); + return false; + } + int8_t* miniRow = s.miniKernMatrix + (newL - 1u) * numRight; + for (uint8_t newR = 1; newR <= numRight; newR++) { + miniRow[newR - 1] = rowBuf[newToOldRight[newR] - 1u]; + } + } + + file.close(); + + s.miniKernLeftEntryCount = lIdx; + s.miniKernRightEntryCount = rIdx; + s.miniKernLeftClassCount = numLeft; + s.miniKernRightClassCount = numRight; + + LOG_DBG("SDCF", "Built mini kern: %u×%u matrix (%u bytes, full was %u×%u = %u bytes)", numLeft, numRight, matrixBytes, + s.header.kernLeftClassCount, s.header.kernRightClassCount, + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount); + return true; +} + +// --- Glyph miss callback --- + +void SdCardFont::applyGlyphMissCallback(uint8_t styleIdx) { + overflowCtx_[styleIdx].self = this; + overflowCtx_[styleIdx].styleIdx = styleIdx; + + auto& s = styles_[styleIdx]; + s.stubData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.stubData.glyphMissCtx = &overflowCtx_[styleIdx]; +} + +// --- Compute per-style file offsets from a base data offset --- + +void SdCardFont::computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset) { + s.intervalsFileOffset = baseOffset; + s.glyphsFileOffset = s.intervalsFileOffset + s.header.intervalCount * sizeof(EpdUnicodeInterval); + s.kernLeftFileOffset = s.glyphsFileOffset + s.header.glyphCount * sizeof(EpdGlyph); + s.kernRightFileOffset = s.kernLeftFileOffset + s.header.kernLeftEntryCount * sizeof(EpdKernClassEntry); + s.kernMatrixFileOffset = s.kernRightFileOffset + s.header.kernRightEntryCount * sizeof(EpdKernClassEntry); + s.ligatureFileOffset = + s.kernMatrixFileOffset + static_cast(s.header.kernLeftClassCount) * s.header.kernRightClassCount; + s.bitmapFileOffset = s.ligatureFileOffset + s.header.ligaturePairCount * sizeof(EpdLigaturePair); +} + +// --- Load --- + +bool SdCardFont::load(const char* path) { + freeAll(); + if (strlen(path) >= sizeof(filePath_)) { + LOG_ERR("SDCF", "Path too long (%zu bytes, max %zu)", strlen(path), sizeof(filePath_) - 1); + return false; + } + strncpy(filePath_, path, sizeof(filePath_) - 1); + filePath_[sizeof(filePath_) - 1] = '\0'; + + FsFile file; + if (!Storage.openFileForRead("SDCF", path, file)) { + LOG_ERR("SDCF", "Failed to open .cpfont: %s", path); + return false; + } + + // Read and validate global header + uint8_t headerBuf[HEADER_SIZE]; + if (file.read(headerBuf, HEADER_SIZE) != HEADER_SIZE) { + LOG_ERR("SDCF", "Failed to read header"); + file.close(); + return false; + } + + if (memcmp(headerBuf, CPFONT_MAGIC, 8) != 0) { + LOG_ERR("SDCF", "Invalid magic bytes"); + file.close(); + return false; + } + + uint16_t fileVersion = readU16(headerBuf + 8); + if (fileVersion != CPFONT_VERSION) { + LOG_ERR("SDCF", "Unsupported version: %u (expected %u)", fileVersion, CPFONT_VERSION); + file.close(); + return false; + } + + // Begin content hash: accumulate global header + uint32_t hash = fnv1a(headerBuf, HEADER_SIZE); + + bool is2Bit = (readU16(headerBuf + 10) & 1) != 0; + + uint8_t styleCount = headerBuf[12]; + if (styleCount == 0 || styleCount > MAX_STYLES) { + LOG_ERR("SDCF", "Invalid style count: %u", styleCount); + file.close(); + return false; + } + + // Read style TOC + for (uint8_t i = 0; i < styleCount; i++) { + uint8_t tocBuf[STYLE_TOC_ENTRY_SIZE]; + if (file.read(tocBuf, STYLE_TOC_ENTRY_SIZE) != STYLE_TOC_ENTRY_SIZE) { + LOG_ERR("SDCF", "Failed to read style TOC entry %u", i); + file.close(); + freeAll(); + return false; + } + + // Accumulate TOC entry into content hash + hash = fnv1a(tocBuf, STYLE_TOC_ENTRY_SIZE, hash); + + uint8_t styleId = tocBuf[0]; + if (styleId >= MAX_STYLES) { + LOG_ERR("SDCF", "Invalid styleId %u in TOC", styleId); + continue; + } + + auto& s = styles_[styleId]; + s.present = true; + s.header.intervalCount = readU32(tocBuf + 4); + s.header.glyphCount = readU32(tocBuf + 8); + s.header.advanceY = tocBuf[12]; + s.header.ascender = readI16(tocBuf + 13); + s.header.descender = readI16(tocBuf + 15); + s.header.kernLeftEntryCount = readU16(tocBuf + 17); + s.header.kernRightEntryCount = readU16(tocBuf + 19); + s.header.kernLeftClassCount = tocBuf[21]; + s.header.kernRightClassCount = tocBuf[22]; + s.header.ligaturePairCount = tocBuf[23]; + s.header.is2Bit = is2Bit; + + // Sanity-check counts to reject malformed files before allocating + static constexpr uint32_t MAX_INTERVALS = 4096; + static constexpr uint32_t MAX_GLYPHS = 65536; + if (s.header.intervalCount > MAX_INTERVALS || s.header.glyphCount > MAX_GLYPHS) { + LOG_ERR("SDCF", "Style %u: unreasonable counts (intervals=%u, glyphs=%u)", styleId, s.header.intervalCount, + s.header.glyphCount); + s.present = false; + continue; + } + + uint32_t dataOffset = readU32(tocBuf + 24); + computeStyleFileOffsets(s, dataOffset); + } + + styleCount_ = styleCount; + contentHash_ = hash; + + // Load full intervals into RAM for each present style + for (uint8_t i = 0; i < MAX_STYLES; i++) { + auto& s = styles_[i]; + if (!s.present) continue; + + s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; + if (!s.fullIntervals) { + LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); + file.close(); + freeAll(); + return false; + } + + if (!file.seekSet(s.intervalsFileOffset)) { + LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i); + file.close(); + freeAll(); + return false; + } + size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval); + if (file.read(reinterpret_cast(s.fullIntervals), intervalsBytes) != static_cast(intervalsBytes)) { + LOG_ERR("SDCF", "Failed to read intervals for style %u", i); + file.close(); + freeAll(); + return false; + } + + // Initialize stub data + memset(&s.stubData, 0, sizeof(s.stubData)); + s.stubData.advanceY = s.header.advanceY; + s.stubData.ascender = s.header.ascender; + s.stubData.descender = s.header.descender; + s.stubData.is2Bit = s.header.is2Bit; + + s.epdFont.data = &s.stubData; + applyGlyphMissCallback(i); + } + + file.close(); + loaded_ = true; + + 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; + const auto& h = styles_[i].header; + LOG_DBG("SDCF", " style[%u]: %u intervals, %u glyphs, advY=%u, asc=%d, desc=%d, kernL=%u, kernR=%u, ligs=%u", i, + h.intervalCount, h.glyphCount, h.advanceY, h.ascender, h.descender, h.kernLeftEntryCount, + h.kernRightEntryCount, h.ligaturePairCount); + } + return true; +} + +// --- Codepoint lookup --- + +int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const { + int left = 0; + int right = static_cast(s.header.intervalCount) - 1; + while (left <= right) { + int mid = left + (right - left) / 2; + const auto& interval = s.fullIntervals[mid]; + if (codepoint < interval.first) { + right = mid - 1; + } else if (codepoint > interval.last) { + left = mid + 1; + } else { + return static_cast(interval.offset + (codepoint - interval.first)); + } + } + return -1; +} + +// --- Prewarm --- + +int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) { + if (!loaded_) return -1; + + unsigned long startMs = millis(); + + // Step 1: Extract unique codepoints from UTF-8 text (shared across all styles). + // Dedup uses O(n^2) linear scan — worst case is MAX_PAGE_GLYPHS (512) unique codepoints + // = ~131K comparisons, but in practice pages contain far fewer unique codepoints so the + // actual cost is much lower. This is dwarfed by SD I/O that follows. Alternatives (hash + // set, bitmap) exceed the 256-byte stack limit or add template bloat. + // Heap-allocated: MAX_PAGE_GLYPHS * 4 = 2048 bytes, too large for stack (limit < 256 bytes) + std::unique_ptr codepoints(new (std::nothrow) uint32_t[MAX_PAGE_GLYPHS]); + if (!codepoints) { + LOG_ERR("SDCF", "Failed to allocate codepoint buffer (%u bytes)", MAX_PAGE_GLYPHS * 4); + return -1; + } + uint32_t cpCount = 0; + + const unsigned char* p = reinterpret_cast(utf8Text); + while (*p && cpCount < MAX_PAGE_GLYPHS) { + uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) break; + + bool found = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == cp) { + found = true; + break; + } + } + if (!found) { + codepoints[cpCount++] = cp; + } + } + + // Always include the replacement character + { + bool hasReplacement = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == REPLACEMENT_GLYPH) { + hasReplacement = true; + break; + } + } + if (!hasReplacement && cpCount < MAX_PAGE_GLYPHS) { + codepoints[cpCount++] = REPLACEMENT_GLYPH; + } + } + + // Add ligature output codepoints from all styles being prewarmed. + // Skip during metadata-only prewarm (layout measurement) to avoid loading + // kern/lig data for all styles upfront (~22KB per style). Kern/lig is + // loaded per-style in prewarmStyle() during the full render prewarm instead. + if (!metadataOnly) { + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + auto& s = styles_[si]; + + loadStyleKernLigatureData(s); + if (s.ligaturePairs && s.header.ligaturePairCount > 0) { + for (uint8_t li = 0; li < s.header.ligaturePairCount && cpCount < MAX_PAGE_GLYPHS; li++) { + uint32_t leftCp = s.ligaturePairs[li].pair >> 16; + uint32_t rightCp = s.ligaturePairs[li].pair & 0xFFFF; + uint32_t outCp = s.ligaturePairs[li].ligatureCp; + + bool hasLeft = false, hasRight = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == leftCp) hasLeft = true; + if (codepoints[i] == rightCp) hasRight = true; + if (hasLeft && hasRight) break; + } + if (!hasLeft || !hasRight) continue; + + bool hasOut = false; + for (uint32_t i = 0; i < cpCount; i++) { + if (codepoints[i] == outCp) { + hasOut = true; + break; + } + } + if (!hasOut) { + codepoints[cpCount++] = outCp; + } + } + } + } + } + + // Sort codepoints for ordered interval building + std::sort(codepoints.get(), codepoints.get() + cpCount); + + // Prewarm each requested style + int totalMissed = 0; + for (uint8_t si = 0; si < MAX_STYLES; si++) { + if (!(styleMask & (1 << si)) || !styles_[si].present) continue; + totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly); + } + + stats_.prewarmTotalMs = millis() - startMs; + return totalMissed; +} + +int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { + auto& s = styles_[styleIdx]; + + // Map codepoints to global glyph indices for this style + struct CpGlyphMapping { + uint32_t codepoint; + int32_t globalIndex; + }; + CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[cpCount]; + if (!mappings) { + LOG_ERR("SDCF", "Failed to allocate mapping array for style %u", styleIdx); + return static_cast(cpCount); + } + + uint32_t validCount = 0; + for (uint32_t i = 0; i < cpCount; i++) { + int32_t idx = findGlobalGlyphIndex(s, codepoints[i]); + if (idx >= 0) { + mappings[validCount].codepoint = codepoints[i]; + mappings[validCount].globalIndex = idx; + validCount++; + } + } + int missed = static_cast(cpCount - validCount); + + if (validCount == 0) { + freeStyleMiniData(s); + delete[] mappings; + s.epdFont.data = &s.stubData; + return missed; + } + + // Build mini intervals from sorted codepoints + freeStyleMiniData(s); + + uint32_t intervalCapacity = validCount; + s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity]; + if (!s.miniIntervals) { + LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); + delete[] mappings; + return static_cast(cpCount); + } + + s.miniIntervalCount = 0; + uint32_t rangeStart = 0; + for (uint32_t i = 1; i <= validCount; i++) { + if (i == validCount || mappings[i].codepoint != mappings[i - 1].codepoint + 1) { + s.miniIntervals[s.miniIntervalCount].first = mappings[rangeStart].codepoint; + s.miniIntervals[s.miniIntervalCount].last = mappings[i - 1].codepoint; + s.miniIntervals[s.miniIntervalCount].offset = rangeStart; + s.miniIntervalCount++; + rangeStart = i; + } + } + + // Allocate mini glyph array + s.miniGlyphCount = validCount; + s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount]; + if (!s.miniGlyphs) { + LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Build sorted read order for sequential I/O + uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; + if (!readOrder) { + LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + for (uint32_t i = 0; i < validCount; i++) readOrder[i] = i; + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; }); + + FsFile file; + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + unsigned long sdStart = millis(); + uint32_t seekCount = 0; + + // Read glyph metadata. lastReadIndex tracks sequential reads to skip redundant + // seeks; INT32_MIN guarantees the first iteration always seeks to the correct + // offset (otherwise when gIdx == 0, the "gIdx != lastReadIndex + 1" check would + // be false and we'd read from the file's current position — the header — which + // decodes to a garbage EpdGlyph with a massive advanceX, inflating any word + // containing that codepoint beyond page width). + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + int32_t gIdx = mappings[mapIdx].globalIndex; + + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + file.seekSet(fileOff); + seekCount++; + } + if (file.read(reinterpret_cast(&s.miniGlyphs[mapIdx]), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Prewarm: short glyph read (style %u, glyph %d)", styleIdx, gIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastReadIndex = gIdx; + } + + uint32_t totalBitmapSize = 0; + + if (!metadataOnly) { + // Compute total bitmap size + for (uint32_t i = 0; i < validCount; i++) { + totalBitmapSize += s.miniGlyphs[i].dataLength; + } + + s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1]; + if (!s.miniBitmap) { + LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + + // Read bitmap data sorted by file offset + std::sort(readOrder, readOrder + validCount, + [&](uint32_t a, uint32_t b) { return s.miniGlyphs[a].dataOffset < s.miniGlyphs[b].dataOffset; }); + + uint32_t miniBitmapOffset = 0; + uint32_t lastBitmapEnd = UINT32_MAX; + for (uint32_t i = 0; i < validCount; i++) { + uint32_t mapIdx = readOrder[i]; + EpdGlyph& glyph = s.miniGlyphs[mapIdx]; + + if (glyph.dataLength == 0) { + glyph.dataOffset = miniBitmapOffset; + continue; + } + + uint32_t fileOff = s.bitmapFileOffset + glyph.dataOffset; + if (fileOff != lastBitmapEnd) { + file.seekSet(fileOff); + seekCount++; + } + if (file.read(s.miniBitmap + miniBitmapOffset, glyph.dataLength) != static_cast(glyph.dataLength)) { + LOG_ERR("SDCF", "Prewarm: short bitmap read (style %u)", styleIdx); + file.close(); + delete[] readOrder; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastBitmapEnd = fileOff + glyph.dataLength; + + glyph.dataOffset = miniBitmapOffset; + miniBitmapOffset += glyph.dataLength; + } + } + + uint32_t sdTime = millis() - sdStart; + file.close(); + delete[] readOrder; + delete[] mappings; + + // Full render prewarm: load the persistent kern classes + ligatures (one-time + // per style, small — the big matrix is NOT loaded here) and then build the + // per-page mini kern matrix restricted to class pairs reachable from this + // page's codepoints. Skip during metadata-only prewarm — layout only needs + // advanceX and the mini kern would be thrown away before rendering. + bool kernLigOk = false; + if (!metadataOnly) { + if (loadStyleKernLigatureData(s)) { + kernLigOk = buildMiniKernMatrix(s, codepoints, cpCount); + } + } + + // Populate miniData and swap + memset(&s.miniData, 0, sizeof(s.miniData)); + s.miniData.bitmap = s.miniBitmap; + s.miniData.glyph = s.miniGlyphs; + s.miniData.intervals = s.miniIntervals; + s.miniData.intervalCount = s.miniIntervalCount; + s.miniData.advanceY = s.header.advanceY; + s.miniData.ascender = s.header.ascender; + s.miniData.descender = s.header.descender; + s.miniData.is2Bit = s.header.is2Bit; + if (kernLigOk) { + applyKernLigaturePointers(s, s.miniData); + } + s.miniData.glyphMissHandler = &SdCardFont::onGlyphMiss; + s.miniData.glyphMissCtx = &overflowCtx_[styleIdx]; + + s.epdFont.data = &s.miniData; + + // Accumulate stats + stats_.sdReadTimeMs += sdTime; + stats_.seekCount += seekCount; + stats_.uniqueGlyphs += validCount; + stats_.bitmapBytes += totalBitmapSize; + + return missed; +} + +// --- Cache management --- + +void SdCardFont::clearCache() { + clearOverflow(); + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + freeStyleMiniData(styles_[i]); + applyGlyphMissCallback(i); + } +} + +// --- Stats --- + +void SdCardFont::logStats(const char* label) { + LOG_DBG("SDCF", "[%s] total=%ums sd_read=%ums seeks=%u glyphs=%u bitmap=%u bytes", label, stats_.prewarmTotalMs, + stats_.sdReadTimeMs, stats_.seekCount, stats_.uniqueGlyphs, stats_.bitmapBytes); +} + +void SdCardFont::resetStats() { stats_ = Stats{}; } + +// --- Public accessors --- + +EpdFont* SdCardFont::getEpdFont(uint8_t style) { + if (style >= MAX_STYLES || !styles_[style].present) return nullptr; + return &styles_[style].epdFont; +} + +bool SdCardFont::hasStyle(uint8_t style) const { return style < MAX_STYLES && styles_[style].present; } + +// --- On-demand glyph loading (overflow buffer) --- + +const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { + auto* oc = static_cast(ctx); + auto* self = oc->self; + uint8_t styleIdx = oc->styleIdx; + + if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr; + const auto& s = self->styles_[styleIdx]; + if (!s.fullIntervals) return nullptr; + + // Check overflow cache first (matching both codepoint and style) + for (uint32_t i = 0; i < self->overflowCount_; i++) { + if (self->overflow_[i].codepoint == codepoint && self->overflow_[i].styleIdx == styleIdx) { + return &self->overflow_[i].glyph; + } + } + + // Look up global glyph index via full intervals + int32_t globalIdx = self->findGlobalGlyphIndex(s, codepoint); + if (globalIdx < 0) return nullptr; + + // Pick overflow slot (ring buffer). Read into temporaries first so the + // existing slot stays valid if SD I/O fails. + uint32_t slot = self->overflowNext_; + bool wasAtCapacity = (self->overflowCount_ == OVERFLOW_CAPACITY); + if (!wasAtCapacity) { + self->overflowCount_++; + } + self->overflowNext_ = (slot + 1) % OVERFLOW_CAPACITY; + + // Read glyph metadata into temporary + FsFile file; + if (!Storage.openFileForRead("SDCF", self->filePath_, file)) { + LOG_ERR("SDCF", "Overflow: failed to open .cpfont"); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } + + EpdGlyph tempGlyph; + uint32_t glyphFileOff = s.glyphsFileOffset + static_cast(globalIdx) * sizeof(EpdGlyph); + file.seekSet(glyphFileOff); + if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Overflow: failed to read glyph metadata for U+%04X style %u", codepoint, styleIdx); + file.close(); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } + + // Read bitmap data into temporary (if any) + uint8_t* tempBitmap = nullptr; + if (tempGlyph.dataLength > 0) { + tempBitmap = new (std::nothrow) uint8_t[tempGlyph.dataLength]; + if (!tempBitmap) { + LOG_ERR("SDCF", "Overflow: failed to allocate %u bytes for U+%04X bitmap", tempGlyph.dataLength, codepoint); + file.close(); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } + file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset); + if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { + LOG_ERR("SDCF", "Overflow: failed to read bitmap for U+%04X", codepoint); + delete[] tempBitmap; + file.close(); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } + } + + file.close(); + + // All reads succeeded — commit to slot (evict old entry if at capacity) + if (wasAtCapacity) { + delete[] self->overflow_[slot].bitmap; + } + self->overflow_[slot].glyph = tempGlyph; + self->overflow_[slot].bitmap = tempBitmap; + self->overflow_[slot].codepoint = codepoint; + self->overflow_[slot].styleIdx = styleIdx; + + LOG_DBG("SDCF", "Overflow: loaded U+%04X style %u on demand (slot %u/%u)", codepoint, styleIdx, slot, + OVERFLOW_CAPACITY); + + return &self->overflow_[slot].glyph; +} + +bool SdCardFont::isOverflowGlyph(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) return true; + } + return false; +} + +const uint8_t* SdCardFont::getOverflowBitmap(const EpdGlyph* glyph) const { + for (uint32_t i = 0; i < overflowCount_; i++) { + if (&overflow_[i].glyph == glyph) { + return overflow_[i].bitmap; + } + } + return nullptr; +} + +SdCardFont* SdCardFont::fromMissCtx(void* ctx) { return static_cast(ctx)->self; } diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h new file mode 100644 index 0000000000..084ad97c7b --- /dev/null +++ b/lib/EpdFont/SdCardFont.h @@ -0,0 +1,187 @@ +#pragma once + +#include + +#include "EpdFont.h" +#include "EpdFontData.h" + +class SdCardFont { + public: + static constexpr uint16_t MAX_PAGE_GLYPHS = 512; + static constexpr uint8_t MAX_STYLES = 4; + + SdCardFont() = default; + ~SdCardFont(); + + // Load .cpfont file: reads header + intervals into RAM, records file layout offsets. + // Supports v4 (multi-style) format. + // Returns true on success. + bool load(const char* path); + + // Pre-read glyphs needed for the given UTF-8 text from SD card. + // styleMask: bitmask of styles to prewarm (bit 0=regular, 1=bold, 2=italic, 3=bolditalic). + // Default 0x0F = all present styles. + // When metadataOnly=true, only glyph metrics are loaded (no bitmap data). + // Returns number of glyphs that couldn't be loaded (0 on full success). + int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false); + + // Free mini data for all styles, restore stub EpdFontData. + void clearCache(); + + // Returns pointer to the managed EpdFont for a given style. + // Returns nullptr if the style is not present. + EpdFont* getEpdFont(uint8_t style = 0); + + // Returns true if the given style is present in this font file. + bool hasStyle(uint8_t style) const; + + // Number of styles present in this font file. + uint8_t styleCount() const { return styleCount_; } + + // Returns true if the glyph pointer points into the overflow buffer. + bool isOverflowGlyph(const EpdGlyph* glyph) const; + + // Returns the bitmap for an on-demand-loaded (overflow) glyph. + const uint8_t* getOverflowBitmap(const EpdGlyph* glyph) const; + + // Extract SdCardFont* from an opaque glyphMissCtx pointer. + // Used by GfxRenderer::getGlyphBitmap() to recover the SdCardFont from EpdFontData::glyphMissCtx. + static SdCardFont* fromMissCtx(void* ctx); + + struct Stats { + uint32_t prewarmTotalMs = 0; + uint32_t sdReadTimeMs = 0; + uint32_t seekCount = 0; + uint32_t uniqueGlyphs = 0; + uint32_t bitmapBytes = 0; + }; + void logStats(const char* label = "SDCF"); + void resetStats(); + const Stats& getStats() const { return stats_; } + + // Content hash of the file header + style TOC entries (computed during load). + // Used to generate deterministic font IDs for section cache invalidation. + uint32_t contentHash() const { return contentHash_; } + + private: + // Per-style metadata (parsed from file header/TOC) + struct CpFontHeader { + uint32_t intervalCount = 0; + uint32_t glyphCount = 0; + uint8_t advanceY = 0; + int16_t ascender = 0; + int16_t descender = 0; + bool is2Bit = false; + uint16_t kernLeftEntryCount = 0; + uint16_t kernRightEntryCount = 0; + uint8_t kernLeftClassCount = 0; + uint8_t kernRightClassCount = 0; + uint8_t ligaturePairCount = 0; + }; + + // All per-style data: file offsets, intervals, kern/lig, prewarm cache, EpdFont + struct PerStyle { + CpFontHeader header{}; + + // File layout offsets for this style's data sections + uint32_t intervalsFileOffset = 0; + uint32_t glyphsFileOffset = 0; + uint32_t kernLeftFileOffset = 0; + uint32_t kernRightFileOffset = 0; + uint32_t kernMatrixFileOffset = 0; + uint32_t ligatureFileOffset = 0; + uint32_t bitmapFileOffset = 0; + + // Full intervals loaded from file (kept in RAM for codepoint lookup) + EpdUnicodeInterval* fullIntervals = nullptr; + + // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). + // The full kern MATRIX is NOT resident — on Literata-class fonts a single + // style's matrix is ~36-42KB contiguous, and 4 styles' worth won't fit + // alongside bitmaps + framebuffer on a 380KB device. Only kernLeftClasses + // and kernRightClasses (small codepoint→classId tables, ~3KB each) stay + // resident; the matrix is reconstructed per-page as miniKernMatrix. + EpdKernClassEntry* kernLeftClasses = nullptr; + EpdKernClassEntry* kernRightClasses = nullptr; + EpdLigaturePair* ligaturePairs = nullptr; + bool kernLigLoaded = false; + + // Stub EpdFontData returned when not prewarmed + EpdFontData stubData{}; + + // Mini EpdFontData built during prewarm + EpdFontData miniData{}; + EpdUnicodeInterval* miniIntervals = nullptr; + EpdGlyph* miniGlyphs = nullptr; + uint8_t* miniBitmap = nullptr; + uint32_t miniIntervalCount = 0; + uint32_t miniGlyphCount = 0; + + // Per-page mini kern matrix (built by buildMiniKernMatrix on each full + // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints + // used on the current page to renumbered class IDs (1..miniKern*ClassCount). + // miniKernMatrix is a small miniKernLeftClassCount × miniKernRightClassCount + // flat matrix. Typical Latin page: ~25×25 matrix = ~625 bytes per style vs + // ~36KB for the full Literata matrix — ~50× reduction. + EpdKernClassEntry* miniKernLeftClasses = nullptr; + EpdKernClassEntry* miniKernRightClasses = nullptr; + uint16_t miniKernLeftEntryCount = 0; + uint16_t miniKernRightEntryCount = 0; + uint8_t miniKernLeftClassCount = 0; + uint8_t miniKernRightClassCount = 0; + int8_t* miniKernMatrix = nullptr; + + // The EpdFont whose data pointer we manage + EpdFont epdFont{&stubData}; + + bool present = false; + }; + + PerStyle styles_[MAX_STYLES] = {}; + uint8_t styleCount_ = 0; + + char filePath_[128] = {}; + + // Overflow context: glyphMissHandler needs to know which style it's serving + struct OverflowContext { + SdCardFont* self; + uint8_t styleIdx; + }; + OverflowContext overflowCtx_[MAX_STYLES] = {}; + + // Shared on-demand overflow buffer (ring buffer of glyphs loaded via glyphMissHandler) + static constexpr uint32_t OVERFLOW_CAPACITY = 8; + struct OverflowEntry { + EpdGlyph glyph; + uint8_t* bitmap = nullptr; + uint32_t codepoint = 0; + uint8_t styleIdx = 0; + }; + OverflowEntry overflow_[OVERFLOW_CAPACITY] = {}; + uint32_t overflowCount_ = 0; + uint32_t overflowNext_ = 0; + + Stats stats_; + uint32_t contentHash_ = 0; + bool loaded_ = false; + + // Per-style helpers + void freeStyleMiniData(PerStyle& s); + void freeStyleAll(PerStyle& s); + void freeStyleKernLigatureData(PerStyle& s); + void freeStyleMiniKern(PerStyle& s); + bool loadStyleKernLigatureData(PerStyle& s); + bool buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, uint32_t cpCount); + void applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const; + void applyGlyphMissCallback(uint8_t styleIdx); + int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; + int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly); + + // Global helpers + void freeAll(); + void clearOverflow(); + static void computeStyleFileOffsets(PerStyle& s, uint32_t baseOffset); + + // Static callback for EpdFontData::glyphMissHandler (per-style via OverflowContext) + static const EpdGlyph* onGlyphMiss(void* ctx, uint32_t codepoint); +}; diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp new file mode 100644 index 0000000000..f6c51bfb6b --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -0,0 +1,104 @@ +#include "SdCardFontManager.h" + +#include +#include +#include +#include +#include + +#include + +SdCardFontManager::~SdCardFontManager() { + for (auto& lf : loaded_) { + delete lf.font; + } +} + +// FNV-1a continuation: seeds with contentHash, then hashes family name + point size. +// Produces a deterministic ID that is stable across load/unload cycles and reboots, +// and changes when font content changes (different header/TOC = different contentHash). +int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize) { + static constexpr uint32_t FNV_PRIME = 16777619u; + uint32_t hash = contentHash; + while (*familyName) { + hash ^= static_cast(*familyName++); + hash *= FNV_PRIME; + } + hash ^= pointSize; + hash *= FNV_PRIME; + int id = static_cast(hash); + return id != 0 ? id : 1; // 0 is reserved as "not found" sentinel +} + +bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize) { + // Unload any previously loaded family first + if (!loadedFamilyName_.empty()) { + unloadAll(renderer); + } + + // Pick the single file whose size is closest to targetPtSize. Loading + // only one size bounds resident memory (intervals + kern/ligature tables + // per style) to one file's worth, vs. N_sizes × per-file overhead. + const SdCardFontFileInfo* selected = nullptr; + int bestDiff = INT32_MAX; + for (const auto& fileInfo : family.files) { + int diff = std::abs(static_cast(fileInfo.pointSize) - static_cast(targetPtSize)); + if (diff < bestDiff) { + bestDiff = diff; + selected = &fileInfo; + } + } + if (!selected) { + LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str()); + return false; + } + + auto* font = new (std::nothrow) SdCardFont(); + if (!font) { + LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str()); + return false; + } + + if (!font->load(selected->path.c_str())) { + LOG_ERR("SDMGR", "Failed to load %s", selected->path.c_str()); + delete font; + return false; + } + + int fontId = computeFontId(font->contentHash(), family.name.c_str(), selected->pointSize); + // Guard against collision with built-in font IDs (astronomically unlikely + // with FNV-1a hashes, but provides a safety net) + if (renderer.getFontMap().count(fontId) != 0) { + LOG_ERR("SDMGR", "Font ID %d collides with existing font, skipping %s", fontId, selected->path.c_str()); + delete font; + return false; + } + renderer.registerSdCardFont(fontId, font); + loaded_.push_back({font, fontId, selected->pointSize}); + + LOG_DBG("SDMGR", "Loaded %s size=%u id=%d styles=%u (target=%u)", selected->path.c_str(), selected->pointSize, fontId, + font->styleCount(), targetPtSize); + + EpdFontFamily fontFamily(font->getEpdFont(0), font->getEpdFont(1), font->getEpdFont(2), font->getEpdFont(3)); + renderer.insertFont(fontId, fontFamily); + + loadedFamilyName_ = family.name; + loadedPointSize_ = selected->pointSize; + return true; +} + +void SdCardFontManager::unloadAll(GfxRenderer& renderer) { + renderer.clearSdCardFonts(); + for (auto& lf : loaded_) { + renderer.removeFont(lf.fontId); + delete lf.font; + } + loaded_.clear(); + loadedFamilyName_.clear(); + loadedPointSize_ = 0; +} + +int SdCardFontManager::getFontId(const std::string& familyName) const { + if (familyName != loadedFamilyName_ || loaded_.empty()) return 0; + return loaded_.front().fontId; +} diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h new file mode 100644 index 0000000000..c60dd63fec --- /dev/null +++ b/lib/EpdFont/SdCardFontManager.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +class GfxRenderer; +class SdCardFont; +struct SdCardFontFamilyInfo; + +class SdCardFontManager { + public: + SdCardFontManager() = default; + ~SdCardFontManager(); + SdCardFontManager(const SdCardFontManager&) = delete; + SdCardFontManager& operator=(const SdCardFontManager&) = delete; + + // Load the single size closest to targetPtSize for a discovered family. + // Only one .cpfont file is loaded; other sizes remain on disk. This keeps + // resident interval + kern/ligature tables to one size's worth of memory + // (see PR #1327 discussion re: Literata OOM). + // Returns true on success. + bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize); + + // Unload everything, unregister from renderer. + void unloadAll(GfxRenderer& renderer); + + // Look up the font ID for the loaded family. Returns 0 if nothing loaded + // or familyName doesn't match. + int getFontId(const std::string& familyName) const; + + // Get name of currently loaded family (empty if none). + const std::string& currentFamilyName() const { return loadedFamilyName_; }; + + // Point size that was actually loaded (closest match to targetPtSize). + // 0 if nothing loaded. + uint8_t currentPointSize() const { return loadedPointSize_; }; + + private: + struct LoadedFont { + SdCardFont* font; // heap-allocated, owned + int fontId; + uint8_t size; + }; + static int computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize); + + std::string loadedFamilyName_; + uint8_t loadedPointSize_ = 0; + std::vector loaded_; +}; diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp new file mode 100644 index 0000000000..8a162f48a1 --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -0,0 +1,172 @@ +#include "SdCardFontRegistry.h" + +#include +#include + +#include +#include + +// --- SdCardFontFamilyInfo helpers --- + +const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t style) const { + for (const auto& f : files) { + if (f.pointSize == size && f.style == style) return &f; + } + return nullptr; +} + +bool SdCardFontFamilyInfo::hasSize(uint8_t size) const { + for (const auto& f : files) { + if (f.pointSize == size) return true; + } + return false; +} + +std::vector SdCardFontFamilyInfo::availableSizes() const { + std::vector sizes; + for (const auto& f : files) { + bool found = false; + for (uint8_t s : sizes) { + if (s == f.pointSize) { + found = true; + break; + } + } + if (!found) sizes.push_back(f.pointSize); + } + std::sort(sizes.begin(), sizes.end()); + return sizes; +} + +// --- SdCardFontRegistry --- + +bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { + // V4 naming: _.cpfont (e.g. Bookerly-SD_14.cpfont) + // Use an ends-with check rather than strstr() so that in-progress downloads + // like "Foo_14.cpfont.tmp" or backups like "Foo_14.cpfont~" aren't accepted. + static constexpr char kExt[] = ".cpfont"; + static constexpr size_t kExtLen = sizeof(kExt) - 1; + const size_t nameLen = strlen(filename); + if (nameLen <= kExtLen) return false; + if (strcmp(filename + nameLen - kExtLen, kExt) != 0) return false; + const char* ext = filename + nameLen - kExtLen; + + size_t baseLen = ext - filename; + if (baseLen == 0 || baseLen > 127) return false; + + char base[128]; + memcpy(base, filename, baseLen); + base[baseLen] = '\0'; + + char* lastUnderscore = strrchr(base, '_'); + if (!lastUnderscore || lastUnderscore == base) return false; + + const char* sizeStr = lastUnderscore + 1; + char* endPtr; + long sizeVal = strtol(sizeStr, &endPtr, 10); + if (endPtr == sizeStr || *endPtr != '\0' || sizeVal < 1 || sizeVal > 255) return false; + size = static_cast(sizeVal); + style = 0; + return true; +} + +void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) { + FsFile dir = Storage.open(dirPath); + if (!dir || !dir.isDirectory()) return; + + char nameBuffer[128]; + while (true) { + FsFile entry = dir.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + entry.close(); + continue; + } + + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip macOS resource fork files (._*) and other hidden files + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + uint8_t size, style; + if (!parseFilename(nameBuffer, size, style)) continue; + + SdCardFontFileInfo info; + info.path = std::string(dirPath) + "/" + nameBuffer; + info.pointSize = size; + info.style = style; + family.files.push_back(std::move(info)); + } + dir.close(); +} + +bool SdCardFontRegistry::discover() { + families_.clear(); + families_.reserve(MAX_SD_FAMILIES); + + FsFile root = Storage.open(FONTS_DIR); + if (!root) { + LOG_DBG("SDREG", "Fonts directory not found: %s", FONTS_DIR); + return false; + } + if (!root.isDirectory()) { + LOG_ERR("SDREG", "Fonts path is not a directory: %s", FONTS_DIR); + root.close(); + return false; + } + + char nameBuffer[128]; + while (true) { + FsFile entry = root.openNextFile(); + if (!entry) break; + if (entry.isDirectory()) { + // Subdirectory = font family + entry.getName(nameBuffer, sizeof(nameBuffer)); + entry.close(); + + // Skip hidden/system directories (macOS ._*, .Trashes, etc.) + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + + SdCardFontFamilyInfo family; + family.name = nameBuffer; + std::string subDirPath = std::string(FONTS_DIR) + "/" + nameBuffer; + scanDirectory(subDirPath.c_str(), family); + + if (!family.files.empty()) { + families_.push_back(std::move(family)); + LOG_DBG("SDREG", "Found family: %s (%d files)", families_.back().name.c_str(), + static_cast(families_.back().files.size())); + } + } else { + entry.close(); + } + } + root.close(); + + // Sort families alphabetically + std::sort(families_.begin(), families_.end(), + [](const SdCardFontFamilyInfo& a, const SdCardFontFamilyInfo& b) { return a.name < b.name; }); + + // Cap at MAX_SD_FAMILIES + if (static_cast(families_.size()) > MAX_SD_FAMILIES) { + families_.resize(MAX_SD_FAMILIES); + } + + LOG_DBG("SDREG", "Discovery complete: %d families", static_cast(families_.size())); + return !families_.empty(); +} + +const SdCardFontFamilyInfo* SdCardFontRegistry::findFamily(const std::string& name) const { + for (const auto& f : families_) { + if (f.name == name) return &f; + } + return nullptr; +} + +int SdCardFontRegistry::getFamilyIndex(const std::string& name) const { + for (int i = 0; i < static_cast(families_.size()); i++) { + if (families_[i].name == name) return i; + } + return -1; +} diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h new file mode 100644 index 0000000000..941668ccb4 --- /dev/null +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +struct SdCardFontFileInfo { + std::string path; // v4 on-disk naming: "/.crosspoint/fonts//_.cpfont" + // e.g. "/.crosspoint/fonts/NotoSansCJK/NotoSansCJK_14.cpfont" + uint8_t pointSize; // parsed from filename: 14 + uint8_t style; // always 0 in v4 (all 4 styles bundled in one file); + // kept for potential future formats +}; + +struct SdCardFontFamilyInfo { + std::string name; // directory name, e.g. "NotoSansCJK" + std::vector files; + + const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; + bool hasSize(uint8_t size) const; + std::vector availableSizes() const; +}; + +class SdCardFontRegistry { + public: + static constexpr int MAX_SD_FAMILIES = 128; + static constexpr const char* FONTS_DIR = "/.crosspoint/fonts"; + + // Scan SD card, populate families_. Returns true if any families found. + bool discover(); + + const std::vector& getFamilies() const { return families_; } + const SdCardFontFamilyInfo* findFamily(const std::string& name) const; + int getFamilyIndex(const std::string& name) const; + int getFamilyCount() const { return static_cast(families_.size()); } + + private: + std::vector families_; // sorted alphabetically + + static bool parseFilename(const char* filename, uint8_t& size, uint8_t& style); + void scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family); +}; diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py new file mode 100644 index 0000000000..3fa36d1ef7 --- /dev/null +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -0,0 +1,882 @@ +#!/usr/bin/env python3 +"""Generate .cpfont binary files for SD card font loading. + +Outputs binary .cpfont files containing glyph metadata and uncompressed +2-bit bitmaps, matching the EpdFontData/EpdGlyph/EpdUnicodeInterval struct +layout on the ESP32-C3 (little-endian, RISC-V). + +Usage: + # Single file with specific presets + python fontconvert_sdcard.py \\ + --intervals latin-ext,greek,cyrillic \\ + --size 14 --style regular \\ + NotoSans-Regular.ttf \\ + -o NotoSansExt_14.cpfont + + # All 4 sizes at once + python fontconvert_sdcard.py \\ + --intervals cjk \\ + --sizes 12,14,16,18 --style regular \\ + NotoSansCJKsc-Regular.otf \\ + --output-dir NotoSansCJK/ + +""" + +import freetype +import struct +import sys +import os +import math +import argparse +from collections import namedtuple + +from fontTools.ttLib import TTFont + +# --- Unicode interval presets --- + +INTERVAL_PRESETS = { + "ascii": [(0x0020, 0x007E)], + "latin1": [(0x0080, 0x00FF)], + "latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F), + (0x1E00, 0x1EFF), (0x2000, 0x206F)], + "greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)], + "cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)], + "georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)], + "armenian": [(0x0530, 0x058F)], + "ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)], + "vietnamese": [(0x01A0, 0x01B0), (0x1EA0, 0x1EF9)], + "punctuation": [(0x2000, 0x206F)], + "cjk": [(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF), + (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)], + "hangul": [(0xAC00, 0xD7AF), (0x1100, 0x11FF), (0x3130, 0x318F)], + # Matches the built-in font intervals from fontconvert.py exactly + "builtin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F), + (0x01A0, 0x01A1), (0x01AF, 0x01B0), (0x01C4, 0x021F), + (0x0300, 0x036F), (0x0400, 0x04FF), + (0x1EA0, 0x1EF9), (0x2000, 0x206F), (0x20A0, 0x20CF), + (0x2070, 0x209F), (0x2190, 0x21FF), (0x2200, 0x22FF), + (0xFB00, 0xFB06)], +} + + +def resolve_intervals(preset_str): + """Resolve comma-separated preset names into a merged, sorted, deduplicated interval list.""" + all_intervals = [] + for name in preset_str.split(","): + name = name.strip().lower() + if name not in INTERVAL_PRESETS: + print(f"Error: unknown interval preset '{name}'", file=sys.stderr) + print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr) + sys.exit(1) + all_intervals.extend(INTERVAL_PRESETS[name]) + + # Always add replacement character + all_intervals.append((0xFFFD, 0xFFFD)) + + # Sort and merge overlapping/adjacent intervals + all_intervals.sort() + merged = [] + for start, end in all_intervals: + if merged and start <= merged[-1][1] + 1: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + return merged + + +GlyphProps = namedtuple("GlyphProps", [ + "width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point" +]) + +# Intermediate data from rasterizing one font style +StyleRasterData = namedtuple("StyleRasterData", [ + "style_id", # 0=regular, 1=bold, 2=italic, 3=bolditalic + "intervals", # validated intervals [(start, end), ...] + "all_glyphs", # [(GlyphProps, packed_bytes), ...] + "total_bitmap_size", # int + "advanceY", "ascender", "descender", + "kern_left_classes", "kern_right_classes", "kern_matrix", + "kern_left_class_count", "kern_right_class_count", + "ligature_pairs", +]) + + +def norm_floor(val): + return int(math.floor(val / (1 << 6))) + + +def norm_ceil(val): + return int(math.ceil(val / (1 << 6))) + + +# Fixed-point (fp4) output conventions (must match EpdFontData.h / fp4 namespace): +# +# advanceX 12.4 unsigned fixed-point (uint16_t). +# 12 integer bits, 4 fractional bits = 1/16-pixel resolution. +# Encoded from FreeType's 16.16 linearHoriAdvance. +# +# kernMatrix 4.4 signed fixed-point (int8_t). +# 4 integer bits, 4 fractional bits = 1/16-pixel resolution. +# Range: -8.0 to +7.9375 pixels. +# Encoded from font design-unit kerning values. +# +# Both share 4 fractional bits so the renderer can add them directly into a +# single int32_t accumulator and defer rounding until pixel placement. + +def fp4_from_ft16_16(val): + """Convert FreeType 16.16 fixed-point to 12.4 fixed-point with rounding.""" + return (val + (1 << 11)) >> 12 + +def fp4_from_design_units(du, scale): + """Convert a font design-unit value to 4.4 fixed-point, clamped to int8_t. + + Multiplies by scale (ppem / units_per_em) and shifts into 4 fractional + bits. The result is rounded to nearest and clamped to [-128, 127]. + """ + raw = round(du * scale * 16) + return max(-128, min(127, raw)) + + +# Standard Unicode ligature codepoints for known input sequences. +# Used as a fallback when the GSUB substitute glyph has no cmap entry. +STANDARD_LIGATURE_MAP = { + (0x66, 0x66): 0xFB00, # ff + (0x66, 0x69): 0xFB01, # fi + (0x66, 0x6C): 0xFB02, # fl + (0x66, 0x66, 0x69): 0xFB03, # ffi + (0x66, 0x66, 0x6C): 0xFB04, # ffl + (0x17F, 0x74): 0xFB05, # long-s + t + (0x73, 0x74): 0xFB06, # st +} + + +def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern): + """Extract kerning from a PairPos subtable (Format 1 or 2).""" + if subtable.Format == 1: + # Individual pairs + for i, coverage_glyph in enumerate(subtable.Coverage.glyphs): + if coverage_glyph not in glyph_to_cp: + continue + pair_set = subtable.PairSet[i] + for pvr in pair_set.PairValueRecord: + if pvr.SecondGlyph not in glyph_to_cp: + continue + xa = 0 + if hasattr(pvr, 'Value1') and pvr.Value1: + xa = getattr(pvr.Value1, 'XAdvance', 0) or 0 + if xa != 0: + key = (coverage_glyph, pvr.SecondGlyph) + raw_kern[key] = raw_kern.get(key, 0) + xa + elif subtable.Format == 2: + # Class-based pairs — iterate by class, not by glyph, to avoid + # O(glyphs²) explosion for CJK fonts with many requested glyphs. + class_def1 = subtable.ClassDef1.classDefs if subtable.ClassDef1 else {} + class_def2 = subtable.ClassDef2.classDefs if subtable.ClassDef2 else {} + coverage_set = set(subtable.Coverage.glyphs) + + # Build reverse mappings: class_id -> list of glyph names + left_by_class = {} # only glyphs in coverage AND glyph_to_cp + for glyph in glyph_to_cp: + if glyph not in coverage_set: + continue + c1 = class_def1.get(glyph, 0) + left_by_class.setdefault(c1, []).append(glyph) + + right_by_class = {} # all glyphs in glyph_to_cp + for glyph in glyph_to_cp: + c2 = class_def2.get(glyph, 0) + right_by_class.setdefault(c2, []).append(glyph) + + # Iterate class pairs (typically << glyph pairs) + for c1, class1_rec in enumerate(subtable.Class1Record): + if c1 not in left_by_class: + continue + for c2, c2_rec in enumerate(class1_rec.Class2Record): + xa = 0 + if hasattr(c2_rec, 'Value1') and c2_rec.Value1: + xa = getattr(c2_rec.Value1, 'XAdvance', 0) or 0 + if xa == 0: + continue + if c2 not in right_by_class: + continue + for lg in left_by_class[c1]: + for rg in right_by_class[c2]: + key = (lg, rg) + raw_kern[key] = raw_kern.get(key, 0) + xa + + +def extract_kerning_fonttools(font_path, codepoints, ppem): + """Extract kerning pairs from a font file using fonttools. + + Returns dict of {(leftCp, rightCp): pixel_adjust} for the given + codepoints. Values are scaled from font design units to integer + pixels at ppem. + """ + font = TTFont(font_path) + units_per_em = font['head'].unitsPerEm + cmap = font.getBestCmap() or {} + + # Build glyph_name -> [codepoints] map (preserves aliases where multiple + # codepoints share a glyph, e.g. space/nbsp) + glyph_to_cps = {} + for cp in codepoints: + gname = cmap.get(cp) + if gname: + glyph_to_cps.setdefault(gname, []).append(cp) + # Flat dict for membership checks and subtable extraction (uses keys only) + glyph_to_cp = glyph_to_cps + + # Collect raw kerning values in font design units + raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units + + # 1. Legacy kern table + if 'kern' in font: + for subtable in font['kern'].kernTables: + if hasattr(subtable, 'kernTable'): + for (lg, rg), val in subtable.kernTable.items(): + if lg in glyph_to_cp and rg in glyph_to_cp: + raw_kern[(lg, rg)] = raw_kern.get((lg, rg), 0) + val + + # 2. GPOS 'kern' feature + if 'GPOS' in font: + gpos = font['GPOS'].table + kern_lookup_indices = set() + if gpos.FeatureList: + for fr in gpos.FeatureList.FeatureRecord: + if fr.FeatureTag == 'kern': + kern_lookup_indices.update(fr.Feature.LookupListIndex) + for li in kern_lookup_indices: + lookup = gpos.LookupList.Lookup[li] + for st in lookup.SubTable: + actual = st + # Unwrap Extension (lookup type 9) wrappers + if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'): + actual = st.ExtSubTable + if hasattr(actual, 'Format'): + _extract_pairpos_subtable(actual, glyph_to_cp, raw_kern) + + font.close() + + # Scale design-unit kerning values to 4.4 fixed-point pixels. + # Expand glyph aliases: if multiple codepoints share a glyph, emit kern + # pairs for all codepoint combinations. + scale = ppem / units_per_em + result = {} # (leftCp, rightCp) -> 4.4 fixed-point adjust + for (lg, rg), du in raw_kern.items(): + adjust = fp4_from_design_units(du, scale) + if adjust != 0: + for lcp in glyph_to_cps[lg]: + for rcp in glyph_to_cps[rg]: + result[(lcp, rcp)] = adjust + return result + + +def derive_kern_classes(kern_map): + """Derive class-based kerning from a pair map. + + Returns (kern_left_classes, kern_right_classes, kern_matrix, + kern_left_class_count, kern_right_class_count) where: + - kern_left_classes: sorted list of (codepoint, classId) tuples + - kern_right_classes: sorted list of (codepoint, classId) tuples + - kern_matrix: flat list of int8 values (left_class_count * right_class_count) + - kern_left_class_count: number of distinct left classes + - kern_right_class_count: number of distinct right classes + """ + if not kern_map: + return [], [], [], 0, 0 + + all_left_cps = {lcp for lcp, _ in kern_map} + all_right_cps = {rcp for _, rcp in kern_map} + + sorted_right_cps = sorted(all_right_cps) + sorted_left_cps = sorted(all_left_cps) + + # Group left codepoints by identical adjustment row + left_profile_to_class = {} + left_class_map = {} + left_class_id = 1 + for lcp in sorted(all_left_cps): + row = tuple(kern_map.get((lcp, rcp), 0) for rcp in sorted_right_cps) + if row not in left_profile_to_class: + left_profile_to_class[row] = left_class_id + left_class_id += 1 + left_class_map[lcp] = left_profile_to_class[row] + + # Group right codepoints by identical adjustment column + right_profile_to_class = {} + right_class_map = {} + right_class_id = 1 + for rcp in sorted(all_right_cps): + col = tuple(kern_map.get((lcp, rcp), 0) for lcp in sorted_left_cps) + if col not in right_profile_to_class: + right_profile_to_class[col] = right_class_id + right_class_id += 1 + right_class_map[rcp] = right_profile_to_class[col] + + kern_left_class_count = left_class_id - 1 + kern_right_class_count = right_class_id - 1 + + if kern_left_class_count > 255 or kern_right_class_count > 255: + print(f"WARNING: kerning class count exceeds uint8_t range " + f"(left={kern_left_class_count}, right={kern_right_class_count}), " + f"dropping kerning for this style", + file=sys.stderr) + return ([], [], [], 0, 0) + + # Build the class x class matrix + kern_matrix = [0] * (kern_left_class_count * kern_right_class_count) + for (lcp, rcp), adjust in kern_map.items(): + lc = left_class_map[lcp] - 1 + rc = right_class_map[rcp] - 1 + kern_matrix[lc * kern_right_class_count + rc] = adjust + + # Build sorted class entry lists + kern_left_classes = sorted(left_class_map.items()) + kern_right_classes = sorted(right_class_map.items()) + + return (kern_left_classes, kern_right_classes, kern_matrix, + kern_left_class_count, kern_right_class_count) + + +def extract_ligatures_fonttools(font_path, codepoints): + """Extract ligature substitution pairs from a font file using fonttools. + + Returns list of (packed_pair, ligature_codepoint) for the given codepoints. + Multi-character ligatures are decomposed into chained pairs. + """ + font = TTFont(font_path) + cmap = font.getBestCmap() or {} + + # Build glyph_name -> codepoint and codepoint -> glyph_name maps + glyph_to_cp = {} + cp_to_glyph = {} + for cp, gname in cmap.items(): + glyph_to_cp[gname] = cp + cp_to_glyph[cp] = gname + + # Collect raw ligature rules: (sequence_of_codepoints) -> ligature_codepoint + raw_ligatures = {} # tuple of codepoints -> ligature codepoint + + if 'GSUB' in font: + gsub = font['GSUB'].table + + LIGATURE_FEATURES = ('liga', 'rlig') + liga_lookup_indices = set() + if gsub.FeatureList: + for fr in gsub.FeatureList.FeatureRecord: + if fr.FeatureTag in LIGATURE_FEATURES: + liga_lookup_indices.update(fr.Feature.LookupListIndex) + + for li in liga_lookup_indices: + lookup = gsub.LookupList.Lookup[li] + for st in lookup.SubTable: + actual = st + # Unwrap Extension (lookup type 7) wrappers + if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'): + actual = st.ExtSubTable + # LigatureSubst is lookup type 4 + if not hasattr(actual, 'ligatures'): + continue + for first_glyph, ligature_list in actual.ligatures.items(): + if first_glyph not in glyph_to_cp: + continue + first_cp = glyph_to_cp[first_glyph] + for lig in ligature_list: + component_cps = [] + valid = True + for comp_glyph in lig.Component: + if comp_glyph not in glyph_to_cp: + valid = False + break + component_cps.append(glyph_to_cp[comp_glyph]) + if not valid: + continue + seq = tuple([first_cp] + component_cps) + if lig.LigGlyph in glyph_to_cp: + lig_cp = glyph_to_cp[lig.LigGlyph] + elif seq in STANDARD_LIGATURE_MAP: + lig_cp = STANDARD_LIGATURE_MAP[seq] + else: + seq_str = ', '.join(f'U+{cp:04X}' for cp in seq) + print(f"ligatures: WARNING: dropping ligature ({seq_str}) -> " + f"glyph '{lig.LigGlyph}': output glyph has no cmap entry " + f"and input sequence is not in STANDARD_LIGATURE_MAP", + file=sys.stderr) + continue + raw_ligatures[seq] = lig_cp + + font.close() + + # Filter: only keep ligatures where all input and output codepoints are + # in our generated glyph set + codepoints_set = set(codepoints) + filtered = {} + for seq, lig_cp in raw_ligatures.items(): + if lig_cp not in codepoints_set: + continue + if all(cp in codepoints_set for cp in seq): + filtered[seq] = lig_cp + + # Decompose into chained pairs + pairs = [] + # First pass: collect all 2-codepoint ligatures + two_char = {seq: lig_cp for seq, lig_cp in filtered.items() if len(seq) == 2} + for seq, lig_cp in two_char.items(): + packed = (seq[0] << 16) | seq[1] + pairs.append((packed, lig_cp)) + + # Second pass: decompose 3+ codepoint ligatures into chained pairs + for seq, lig_cp in filtered.items(): + if len(seq) < 3: + continue + prefix = seq[:-1] + last_cp = seq[-1] + if prefix in filtered: + intermediate_cp = filtered[prefix] + packed = (intermediate_cp << 16) | last_cp + pairs.append((packed, lig_cp)) + else: + print(f"ligatures: skipping {len(seq)}-char ligature " + f"({', '.join(f'U+{cp:04X}' for cp in seq)}) -> U+{lig_cp:04X}: " + f"no intermediate ligature for prefix", file=sys.stderr) + + # Sort by packed pair key — on-device lookup uses binary search + pairs.sort(key=lambda p: p[0]) + return pairs + + +def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False): + """Rasterize all glyphs for one font style. Returns StyleRasterData.""" + style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"} + style_label = style_names.get(style_id, str(style_id)) + + face = freetype.Face(fontfile) + load_flags = freetype.FT_LOAD_RENDER + if force_autohint: + load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT + + def load_glyph(code_point): + glyph_index = face.get_char_index(code_point) + if glyph_index > 0: + face.load_glyph(glyph_index, load_flags) + return face + return None + + # Validate intervals: remove codepoints not present in the font + print(f" [{style_label}] Validating intervals against font...", file=sys.stderr) + validated_intervals = [] + for i_start, i_end in intervals: + start = i_start + for code_point in range(i_start, i_end + 1): + f = load_glyph(code_point) + if f is None: + if start < code_point: + validated_intervals.append((start, code_point - 1)) + start = code_point + 1 + if start <= i_end: + validated_intervals.append((start, i_end)) + + intervals = validated_intervals + total_glyphs = sum(end - start + 1 for start, end in intervals) + print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr) + + # Set font size at 150 DPI (matching fontconvert.py) + face.set_char_size(size << 6, size << 6, 150, 150) + + # Rasterize all glyphs + total_bitmap_size = 0 + all_glyphs = [] + + for i_start, i_end in intervals: + for code_point in range(i_start, i_end + 1): + f = load_glyph(code_point) + if f is None: + glyph = GlyphProps(0, 0, 0, 0, 0, 0, total_bitmap_size, code_point) + all_glyphs.append((glyph, b'')) + continue + + bitmap = f.glyph.bitmap + + # Build 4-bit greyscale bitmap (same logic as fontconvert.py) + pixels4g = [] + px = 0 + for i, v in enumerate(bitmap.buffer): + x = i % bitmap.width + if x % 2 == 0: + px = (v >> 4) + else: + px = px | (v & 0xF0) + pixels4g.append(px) + px = 0 + if x == bitmap.width - 1 and bitmap.width % 2 > 0: + pixels4g.append(px) + px = 0 + + # Downsample to 2-bit bitmap + pixels2b = [] + px = 0 + pitch = (bitmap.width // 2) + (bitmap.width % 2) + for y in range(bitmap.rows): + for x in range(bitmap.width): + px = px << 2 + bm = pixels4g[y * pitch + (x // 2)] + bm = (bm >> ((x % 2) * 4)) & 0xF + + if bm >= 12: + px += 3 + elif bm >= 8: + px += 2 + elif bm >= 4: + px += 1 + + if (y * bitmap.width + x) % 4 == 3: + pixels2b.append(px) + px = 0 + if (bitmap.width * bitmap.rows) % 4 != 0: + px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2 + pixels2b.append(px) + + packed = bytes(pixels2b) + glyph = GlyphProps( + width=bitmap.width, + height=bitmap.rows, + advance_x=fp4_from_ft16_16(f.glyph.linearHoriAdvance), + left=f.glyph.bitmap_left, + top=f.glyph.bitmap_top, + data_length=len(packed), + data_offset=total_bitmap_size, + code_point=code_point, + ) + total_bitmap_size += len(packed) + all_glyphs.append((glyph, packed)) + + # Get font metrics from pipe character (same heuristic as fontconvert.py) + load_glyph(ord('|')) + + advanceY = norm_ceil(face.size.height) + ascender = norm_ceil(face.size.ascender) + descender = norm_floor(face.size.descender) + + print(f" [{style_label}] Metrics: advanceY={advanceY}, ascender={ascender}, descender={descender}", file=sys.stderr) + print(f" [{style_label}] Bitmap: {total_bitmap_size} bytes ({total_bitmap_size / 1024:.1f} KB)", file=sys.stderr) + + # --- Extract kerning and ligatures --- + ppem = size * 150.0 / 72.0 + all_cps = set(g.code_point for g, _ in all_glyphs) + + kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem) + print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr) + + (kern_left_classes, kern_right_classes, kern_matrix, + kern_left_class_count, kern_right_class_count) = derive_kern_classes(kern_map) + + if kern_map: + matrix_size = kern_left_class_count * kern_right_class_count + entries_size = (len(kern_left_classes) + len(kern_right_classes)) * 3 + print(f" [{style_label}] Kerning classes: {kern_left_class_count} left, {kern_right_class_count} right, " + f"{matrix_size + entries_size} bytes", file=sys.stderr) + + ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps) + if len(ligature_pairs) > 255: + print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating", + file=sys.stderr) + ligature_pairs = ligature_pairs[:255] + print(f" [{style_label}] Ligatures: {len(ligature_pairs)} pairs", file=sys.stderr) + + return StyleRasterData( + style_id=style_id, + intervals=intervals, + all_glyphs=all_glyphs, + total_bitmap_size=total_bitmap_size, + advanceY=advanceY, + ascender=ascender, + descender=descender, + kern_left_classes=kern_left_classes, + kern_right_classes=kern_right_classes, + kern_matrix=kern_matrix, + kern_left_class_count=kern_left_class_count, + kern_right_class_count=kern_right_class_count, + ligature_pairs=ligature_pairs, + ) + + +# --- Binary packing helpers --- + +# EpdGlyph struct: 16 bytes, little-endian +GLYPH_STRUCT_FORMAT = " StyleRasterData + for style_id in sorted(style_fonts.keys()): + fontfile = style_fonts[style_id] + print(f" Rasterizing style {style_id}...", file=sys.stderr) + raster_data[style_id] = rasterize_font_style( + fontfile, size, intervals, style_id=style_id, + force_autohint=force_autohint) + + # Pack binary sections for each style + packed_sections = {} # style_id -> tuple of section bytearrays + for style_id, sd in raster_data.items(): + packed_sections[style_id] = pack_style_sections(sd) + + # Calculate data offsets (after header + TOC) + data_start = HEADER_SIZE + style_count * STYLE_TOC_ENTRY_SIZE + current_offset = data_start + + style_offsets = {} # style_id -> absolute file offset + for style_id in sorted(packed_sections.keys()): + style_offsets[style_id] = current_offset + current_offset += style_sections_total_size(packed_sections[style_id]) + + # Build global header + # V4 header: magic(8) + version(2) + flags(2) + styleCount(1) + reserved(19) = 32 + header = struct.pack("<8sHHB19s", MAGIC, VERSION, flags, style_count, bytes(19)) + assert len(header) == HEADER_SIZE + + # Build style TOC entries + # Each entry: styleId(1) + pad(3) + intervalCount(4) + glyphCount(4) + + # advanceY(1) + ascender(2) + descender(2) + kernL(2) + kernR(2) + + # kernLCls(1) + kernRCls(1) + ligCount(1) + dataOffset(4) + reserved(4) = 32 + STYLE_TOC_FORMAT = " 255: + print(f"ERROR: advanceY ({sd.advanceY}) exceeds uint8 range for " + f"style {style_id} size {size}. This likely means the font " + f"size is too large for this format.", + file=sys.stderr) + sys.exit(1) + toc_data += struct.pack(STYLE_TOC_FORMAT, + style_id, + len(sd.intervals), len(sd.all_glyphs), + sd.advanceY, sd.ascender, sd.descender, + len(sd.kern_left_classes), len(sd.kern_right_classes), + sd.kern_left_class_count, sd.kern_right_class_count, + len(sd.ligature_pairs), + style_offsets[style_id]) + + # Write output + os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True) + total_file_size = 0 + with open(output_path, "wb") as f: + f.write(header) + f.write(toc_data) + for style_id in sorted(packed_sections.keys()): + for section in packed_sections[style_id]: + f.write(section) + total_file_size = f.tell() + + # Print summary + print(f" Output: {output_path} (v4, {style_count} styles)", file=sys.stderr) + print(f" Header+TOC: {HEADER_SIZE + len(toc_data)} bytes", file=sys.stderr) + for style_id in sorted(raster_data.keys()): + sd = raster_data[style_id] + secs = packed_sections[style_id] + style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"} + sname = style_names.get(style_id, str(style_id)) + ssize = style_sections_total_size(secs) + print(f" {sname}: {len(sd.all_glyphs)} glyphs, {len(sd.intervals)} intervals, " + f"{ssize} bytes", file=sys.stderr) + print(f" Total: {total_file_size} bytes ({total_file_size / 1024 / 1024:.2f} MB)", file=sys.stderr) + return total_file_size + + +def main(): + parser = argparse.ArgumentParser( + description="Generate .cpfont files for SD card font loading.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f"Available interval presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}" + ) + + # Font file (positional, optional for multi-style mode) + parser.add_argument("fontfile", nargs="?", default=None, + help="Path to the font file (single-style mode).") + parser.add_argument("--intervals", dest="intervals", + help="Comma-separated interval presets (e.g., 'latin-ext,greek,cyrillic').") + parser.add_argument("--size", type=int, dest="size", + help="Single font size to generate.") + parser.add_argument("--sizes", dest="sizes", + help="Comma-separated sizes (e.g., '12,14,16,18').") + parser.add_argument("--style", dest="style", default="regular", + choices=["regular", "bold", "italic", "bolditalic"], + help="Font style for single-style mode (default: regular).") + parser.add_argument("--name", dest="name", + help="Font family name for output filenames (default: derived from font filename).") + parser.add_argument("--force-autohint", dest="force_autohint", action="store_true", + help="Force FreeType auto-hinter instead of native font hinting.") + parser.add_argument("-o", "--output", dest="output", + help="Output file path (for single-size mode).") + parser.add_argument("--output-dir", dest="output_dir", + help="Output directory for multi-size mode.") + parser.add_argument("--list-presets", action="store_true", + help="List available interval presets and exit.") + + # Multi-style mode: per-style font file arguments (generates v4 .cpfont) + parser.add_argument("--regular", dest="font_regular", + help="Font file for regular style (enables multi-style v4 mode).") + parser.add_argument("--bold", dest="font_bold", + help="Font file for bold style.") + parser.add_argument("--italic", dest="font_italic", + help="Font file for italic style.") + parser.add_argument("--bolditalic", dest="font_bolditalic", + help="Font file for bold-italic style.") + + args = parser.parse_args() + + if args.list_presets: + print("Available interval presets:") + for name, ranges in sorted(INTERVAL_PRESETS.items()): + total = sum(e - s + 1 for s, e in ranges) + print(f" {name:15s} {len(ranges)} range(s), ~{total} codepoints") + sys.exit(0) + + # Detect multi-style mode + style_fonts = {} + if args.font_regular: + style_fonts[0] = args.font_regular + if args.font_bold: + style_fonts[1] = args.font_bold + if args.font_italic: + style_fonts[2] = args.font_italic + if args.font_bolditalic: + style_fonts[3] = args.font_bolditalic + + is_multistyle = len(style_fonts) > 0 + fontfile = args.fontfile + + # Require --intervals + if not args.intervals: + print("Error: --intervals is required (e.g., --intervals latin-ext,greek,cyrillic)", file=sys.stderr) + print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr) + sys.exit(1) + + intervals = resolve_intervals(args.intervals) + + # Determine sizes + if args.sizes: + sizes = [int(s.strip()) for s in args.sizes.split(",")] + elif args.size: + sizes = [args.size] + else: + print("Error: --size or --sizes is required", file=sys.stderr) + sys.exit(1) + + # Validate early: single-style mode requires a font file + if not is_multistyle and not fontfile: + print("Error: fontfile is required in single-style mode", file=sys.stderr) + sys.exit(1) + + # Determine font name + if args.name: + font_name = args.name + elif is_multistyle: + # Derive from the regular font file + ref_file = style_fonts[min(style_fonts.keys())] + base = os.path.splitext(os.path.basename(ref_file))[0] + for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic", + "-regular", "-bold", "-italic", "-bolditalic"]: + if base.endswith(suffix): + base = base[:-len(suffix)] + break + font_name = base + else: + base = os.path.splitext(os.path.basename(fontfile))[0] + for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic", + "-regular", "-bold", "-italic", "-bolditalic"]: + if base.endswith(suffix): + base = base[:-len(suffix)] + break + font_name = base + + if not is_multistyle: + # Single font file provided: wrap as a single-style v4 font + style_map = {"regular": 0, "bold": 1, "italic": 2, "bolditalic": 3} + style_fonts[style_map[args.style]] = fontfile + + # Always generate v4 format + if args.output and len(sizes) != 1: + print("Error: --output can only be used with a single size", file=sys.stderr) + sys.exit(1) + output_dir = args.output_dir if args.output_dir else f"{font_name}/" + total_size = 0 + for sz in sizes: + if args.output and len(sizes) == 1: + output_path = args.output + else: + filename = f"{font_name}_{sz}.cpfont" + output_path = os.path.join(output_dir, filename) + print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr) + total_size += generate_cpfont_multistyle( + style_fonts, sz, intervals, output_path, + force_autohint=args.force_autohint) + print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/lib/EpdFont/scripts/generate-sd-fonts.sh b/lib/EpdFont/scripts/generate-sd-fonts.sh new file mode 100755 index 0000000000..5faf44402e --- /dev/null +++ b/lib/EpdFont/scripts/generate-sd-fonts.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Generate recommended SD card font packs for CrossPoint. +# +# Prerequisites: +# pip install freetype-py fonttools +# +# Source fonts are in ../builtinFonts/source/: +# Bookerly/, NotoSans/, OpenDyslexic/, Ubuntu/ — committed to git +# NotoSansCJK/ — downloaded automatically by this script (gitignored) +# +# Output goes to ./output/ (copy to SD card at /.crosspoint/fonts/) + +set -e +cd "$(dirname "$0")" + +SCRIPT="./fontconvert_sdcard.py" +FONT_DIR="../builtinFonts/source" +OUTPUT_BASE="./output" + +SIZES="12,14,16,18" + +# --- Download fonts that aren't checked into git --- + +NOTOSANSCJK_DIR="$FONT_DIR/NotoSansCJK" +NOTOSANSCJK_FONT="$NOTOSANSCJK_DIR/NotoSansCJKsc-Regular.otf" + +if [ ! -f "$NOTOSANSCJK_FONT" ]; then + echo "Downloading NotoSansCJKsc-Regular.otf..." + mkdir -p "$NOTOSANSCJK_DIR" + curl -fSL -o "$NOTOSANSCJK_FONT" \ + "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf" + echo "Downloaded $(du -h "$NOTOSANSCJK_FONT" | cut -f1) to $NOTOSANSCJK_FONT" +fi + +# Clean output directories to ensure a fresh build +echo "Cleaning output directories..." +rm -rf "$OUTPUT_BASE/NotoSansExtended/" "$OUTPUT_BASE/Bookerly-SD/" "$OUTPUT_BASE/NotoSansCJK/" + +# Run all three font families in parallel +echo "=== Starting parallel font generation ===" + +echo "[1/3] NotoSansExtended (Latin-ext + Greek + Cyrillic + Georgian + Armenian + Ethiopic)" +python3 "$SCRIPT" \ + "$FONT_DIR/NotoSans/NotoSans-Regular.ttf" \ + --intervals latin-ext,greek,cyrillic,georgian,armenian,ethiopic \ + --sizes "$SIZES" --style regular \ + --name NotoSansExtended \ + --output-dir "$OUTPUT_BASE/NotoSansExtended/" & +PID_NOTO=$! + +echo "[2/3] Bookerly-SD (multi-style)" +python3 "$SCRIPT" \ + --regular "$FONT_DIR/Bookerly/Bookerly-Regular.ttf" \ + --bold "$FONT_DIR/Bookerly/Bookerly-Bold.ttf" \ + --italic "$FONT_DIR/Bookerly/Bookerly-Italic.ttf" \ + --bolditalic "$FONT_DIR/Bookerly/Bookerly-BoldItalic.ttf" \ + --intervals builtin \ + --sizes "$SIZES" --force-autohint \ + --name Bookerly-SD \ + --output-dir "$OUTPUT_BASE/Bookerly-SD/" & +PID_BOOKERLY=$! + +echo "[3/3] NotoSansCJK (CJK + ASCII + Punctuation)" +python3 "$SCRIPT" \ + "$FONT_DIR/NotoSansCJK/NotoSansCJKsc-Regular.otf" \ + --intervals ascii,latin1,punctuation,cjk \ + --sizes "$SIZES" --style regular \ + --name NotoSansCJK \ + --output-dir "$OUTPUT_BASE/NotoSansCJK/" & +PID_CJK=$! + +# Wait for all and track failures +FAILED=0 +wait $PID_NOTO || { echo "ERROR: NotoSansExtended generation failed"; FAILED=1; } +wait $PID_BOOKERLY || { echo "ERROR: Bookerly-SD generation failed"; FAILED=1; } +wait $PID_CJK || { echo "ERROR: NotoSansCJK generation failed"; FAILED=1; } + +if [ $FAILED -ne 0 ]; then + echo "=== Some font generations failed ===" + exit 1 +fi + +echo "" +echo "=== Done ===" +echo "Copy the contents of $OUTPUT_BASE/ to your SD card at /.crosspoint/fonts/" diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 3ad9337d15..f081060de5 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -150,6 +150,25 @@ void ParsedText::layoutAndExtractLines( // Apply fixed transforms before any per-line layout work. applyParagraphIndent(); + // Ensure SD card font glyph metrics are loaded before measuring word widths. + // For flash-based fonts isSdCardFont() returns false and this block is skipped + // entirely — no heap allocation. For SD card fonts this reads glyph metadata + // (advanceX only, no bitmaps) for all unique codepoints in this paragraph so + // that calculateWordWidths() can measure text without on-demand SD I/O. + if (renderer.isSdCardFont(fontId)) { + size_t totalSize = hyphenationEnabled ? 1 : 0; + if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces + for (const auto& w : words) totalSize += w.size(); + std::string allText; + allText.reserve(totalSize); + for (size_t i = 0; i < words.size(); i++) { + if (i > 0) allText += ' '; + allText += words[i]; + } + if (hyphenationEnabled) allText += '-'; + renderer.ensureSdCardFontReady(fontId, allText.c_str()); + } + const int pageWidth = viewportWidth; auto wordWidths = calculateWordWidths(renderer, fontId); diff --git a/lib/GfxRenderer/FontCacheManager.cpp b/lib/GfxRenderer/FontCacheManager.cpp index 8c3083ca34..d8e4685507 100644 --- a/lib/GfxRenderer/FontCacheManager.cpp +++ b/lib/GfxRenderer/FontCacheManager.cpp @@ -2,18 +2,35 @@ #include #include +#include #include -FontCacheManager::FontCacheManager(const std::map& fontMap) : fontMap_(fontMap) {} +FontCacheManager::FontCacheManager(const std::map& fontMap, + const std::map& sdCardFonts) + : fontMap_(fontMap), sdCardFonts_(sdCardFonts) {} void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; } void FontCacheManager::clearCache() { if (fontDecompressor_) fontDecompressor_->clearCache(); + for (auto& [id, font] : sdCardFonts_) { + font->clearCache(); + } } void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) { + // SD card font prewarm path: prewarm all requested styles in one call + auto sdIt = sdCardFonts_.find(fontId); + if (sdIt != sdCardFonts_.end()) { + int missed = sdIt->second->prewarm(utf8Text, styleMask); + if (missed > 0) { + LOG_DBG("FCM", "prewarmCache(SD): %d glyph(s) not found (styleMask=0x%02X)", missed, styleMask); + } + return; + } + + // Standard compressed font prewarm path: loop over all requested styles if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return; for (uint8_t i = 0; i < 4; i++) { @@ -30,10 +47,16 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st void FontCacheManager::logStats(const char* label) { if (fontDecompressor_) fontDecompressor_->logStats(label); + for (auto& [id, font] : sdCardFonts_) { + font->logStats(label); + } } void FontCacheManager::resetStats() { if (fontDecompressor_) fontDecompressor_->resetStats(); + for (auto& [id, font] : sdCardFonts_) { + font->resetStats(); + } } bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; } diff --git a/lib/GfxRenderer/FontCacheManager.h b/lib/GfxRenderer/FontCacheManager.h index f4c14dba7d..27dde4a00e 100644 --- a/lib/GfxRenderer/FontCacheManager.h +++ b/lib/GfxRenderer/FontCacheManager.h @@ -7,10 +7,11 @@ #include class FontDecompressor; +class SdCardFont; class FontCacheManager { public: - explicit FontCacheManager(const std::map& fontMap); + FontCacheManager(const std::map& fontMap, const std::map& sdCardFonts); void setFontDecompressor(FontDecompressor* d); @@ -45,6 +46,7 @@ class FontCacheManager { private: const std::map& fontMap_; + const std::map& sdCardFonts_; FontDecompressor* fontDecompressor_ = nullptr; enum class ScanMode : uint8_t { None, Scanning }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index e4e5bb5315..b7032953fc 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -21,9 +22,34 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep // must consume it (draw the glyph) before requesting another bitmap. return fd->getBitmap(fontData, glyph, glyphIndex); } + // For SD card fonts, check if the glyph was loaded on demand into the overflow + // buffer. getOverflowBitmap() returns: + // - bitmap pointer for overflow glyphs with bitmap data + // - nullptr for overflow glyphs without bitmap data (e.g. space: width=0, height=0) + // - nullptr for non-overflow glyphs (normal prewarmed path) + // We distinguish overflow-with-no-bitmap from non-overflow by checking isOverflowGlyph(). + if (fontData->glyphMissCtx) { + auto* sdFont = SdCardFont::fromMissCtx(fontData->glyphMissCtx); + if (sdFont->isOverflowGlyph(glyph)) { + return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs + } + } return &fontData->bitmap[glyph->dataOffset]; } +void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text) const { + auto it = sdCardFonts_.find(fontId); + if (it != sdCardFonts_.end()) { + // Metadata-only: loads glyph metrics (advanceX) without bitmap data. + // Saves ~50-100KB heap vs full prewarm — layout only needs advance widths. + // Prewarm all present styles (0x0F) for layout measurement. + int missed = it->second->prewarm(utf8Text, 0x0F, /*metadataOnly=*/true); + if (missed > 0) { + LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed); + } + } +} + void GfxRenderer::begin() { frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { @@ -38,7 +64,12 @@ void GfxRenderer::begin() { bwBufferChunks.assign((frameBufferSize + bwBufferChunkSize - 1) / bwBufferChunkSize, nullptr); } -void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } +void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { + auto result = fontMap.insert({fontId, font}); + if (!result.second) { + LOG_ERR("GFX", "Font ID %d already registered, ignoring duplicate", fontId); + } +} // Translate logical (x,y) coordinates to physical panel coordinates based on current orientation // This should always be inlined for better performance diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b8fea89500..4cc6c6192b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -4,6 +4,7 @@ #include class FontCacheManager; +class SdCardFont; #include #include @@ -57,6 +58,10 @@ class GfxRenderer { size_t bwBufferChunkSize = BW_BUFFER_CHUNK_SIZE; std::vector bwBufferChunks; std::map fontMap; + // Mutable because ensureSdCardFontReady() is const (called from layout code that + // holds a const GfxRenderer&) but triggers SD card reads and heap allocation + // inside the SdCardFont objects. Same pragmatic compromise as fontCacheManager_. + mutable std::map sdCardFonts_; // Mutable because drawText() is const but needs to delegate scan-mode // recording to the (non-const) FontCacheManager. Same pragmatic compromise @@ -97,9 +102,19 @@ class GfxRenderer { // Setup void begin(); // must be called right after display.begin() void insertFont(int fontId, EpdFontFamily font); + void removeFont(int fontId) { fontMap.erase(fontId); } void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; } FontCacheManager* getFontCacheManager() const { return fontCacheManager_; } const std::map& getFontMap() const { return fontMap; } + void registerSdCardFont(int fontId, SdCardFont* font) { sdCardFonts_[fontId] = font; } + void unregisterSdCardFont(int fontId) { sdCardFonts_.erase(fontId); } + void clearSdCardFonts() { sdCardFonts_.clear(); } + const std::map& getSdCardFonts() const { return sdCardFonts_; } + bool isSdCardFont(int fontId) const { return sdCardFonts_.count(fontId) > 0; } + // Ensure SD card font glyph data is loaded for the given text. Called from layout code + // (which holds a const GfxRenderer&) before measuring word widths. Safe to call on + // non-SD fonts (no-op). + void ensureSdCardFontReady(int fontId, const char* utf8Text) const; // Orientation control (affects logical width/height and coordinate transforms) void setOrientation(const Orientation o) { orientation.store(static_cast(o), std::memory_order_relaxed); } diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index cd51a99f51..7d81e24745 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -8,8 +8,28 @@ #include #include +#include "SdCardFontGlobals.h" #include "fontIds.h" +// Font ID 0 is reserved as the SD card font "not found" sentinel +// (SdCardFontManager::computeFontId() never returns 0). Guard against any +// hash accidentally producing 0 — would cause silent fallback to built-in. +static_assert(BOOKERLY_12_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(BOOKERLY_14_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(BOOKERLY_16_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(BOOKERLY_18_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(NOTOSANS_12_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(NOTOSANS_14_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(NOTOSANS_16_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(NOTOSANS_18_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(OPENDYSLEXIC_8_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(OPENDYSLEXIC_10_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(OPENDYSLEXIC_12_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(OPENDYSLEXIC_14_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(UI_10_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(UI_12_FONT_ID != 0, "Font ID collision with sentinel"); +static_assert(SMALL_FONT_ID != 0, "Font ID collision with sentinel"); + // Initialize the static instance CrossPointSettings CrossPointSettings::instance; @@ -244,6 +264,20 @@ bool CrossPointSettings::loadFromBinaryFile() { } float CrossPointSettings::getReaderLineCompression() const { + // SD card fonts inherit the Bookerly-style line compression (the most neutral + // values) since we have no per-family metadata for SD fonts. + if (sdFontFamilyName[0] != '\0') { + switch (lineSpacing) { + case TIGHT: + return 0.95f; + case NORMAL: + default: + return 1.0f; + case WIDE: + return 1.1f; + } + } + switch (fontFamily) { case BOOKERLY: default: @@ -311,11 +345,11 @@ int CrossPointSettings::getRefreshFrequency() const { } } -int CrossPointSettings::getReaderFontId() const { - switch (fontFamily) { +int CrossPointSettings::getBuiltinReaderFontId(uint8_t family, uint8_t size) { + switch (family) { case BOOKERLY: default: - switch (fontSize) { + switch (size) { case SMALL: return BOOKERLY_12_FONT_ID; case MEDIUM: @@ -327,7 +361,7 @@ int CrossPointSettings::getReaderFontId() const { return BOOKERLY_18_FONT_ID; } case NOTOSANS: - switch (fontSize) { + switch (size) { case SMALL: return NOTOSANS_12_FONT_ID; case MEDIUM: @@ -339,7 +373,7 @@ int CrossPointSettings::getReaderFontId() const { return NOTOSANS_18_FONT_ID; } case OPENDYSLEXIC: - switch (fontSize) { + switch (size) { case SMALL: return OPENDYSLEXIC_8_FONT_ID; case MEDIUM: @@ -352,3 +386,14 @@ int CrossPointSettings::getReaderFontId() const { } } } + +int CrossPointSettings::getReaderFontId() const { + // SD card font takes priority when one is selected globally. + // resolveSdCardFontId() returns 0 if the named family isn't loaded + // (e.g. SD card removed since selection) — fall through to built-in. + if (sdFontFamilyName[0] != '\0') { + int id = resolveSdCardFontId(sdFontFamilyName); + if (id != 0) return id; + } + return getBuiltinReaderFontId(fontFamily, fontSize); +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 8d0f7fbf25..f124c0303a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -88,8 +88,9 @@ class CrossPointSettings { FRONT_BUTTON_HARDWARE_COUNT }; - // Font family options + // Font family options (built-in fonts only; SD card fonts use sdFontFamilyName) enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT }; + static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT; // Font size options enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT }; enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; @@ -211,6 +212,8 @@ class CrossPointSettings { uint8_t frontButtonRight = FRONT_HW_RIGHT; // Reader font settings uint8_t fontFamily = BOOKERLY; + // SD card font family name (empty = use built-in fontFamily) + char sdFontFamilyName[32] = ""; uint8_t fontSize = MEDIUM; uint8_t lineSpacing = NORMAL; uint8_t paragraphAlignment = JUSTIFIED; @@ -319,6 +322,11 @@ class CrossPointSettings { static constexpr uint16_t getPowerButtonDuration() { return 400; } int getReaderFontId() const; + // Pure built-in lookup (size enum + family enum -> font ID). Independent of + // SD-card font selection. Used by the per-book fontFamilyOverride path so + // an override forces back to a known built-in even when an SD font is the + // global default. + static int getBuiltinReaderFontId(uint8_t family, uint8_t size); // If count_only is true, returns the number of settings items that would be written. uint8_t writeSettings(FsFile& file, bool count_only = false) const; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d079e189d0..2878ae9994 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -189,6 +189,13 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) doc["frontButtonLeft"] = s.frontButtonLeft; doc["frontButtonRight"] = s.frontButtonRight; + // Font family uses a DynamicEnumCtx in SettingsList (no valuePtr) so the generic + // loop above skips it. Save manually. + doc["fontFamily"] = s.fontFamily; + if (s.sdFontFamilyName[0] != '\0') { + doc["sdFontFamilyName"] = s.sdFontFamilyName; + } + String json; serializeJson(doc, json); return Storage.writeFile(path, json); @@ -268,6 +275,14 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT); CrossPointSettings::validateFrontButtonMapping(s); + // Font family uses a DynamicEnumCtx in SettingsList (no valuePtr) so the generic + // loop above skips it. Load manually. + s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)CrossPointSettings::BOOKERLY, + CrossPointSettings::BUILTIN_FONT_COUNT, CrossPointSettings::BOOKERLY); + const char* sfn = doc["sdFontFamilyName"] | ""; + strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1); + s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0'; + LOG_DBG("CPS", "Settings loaded from file"); return true; diff --git a/src/SdCardFontGlobals.h b/src/SdCardFontGlobals.h new file mode 100644 index 0000000000..03d8517da3 --- /dev/null +++ b/src/SdCardFontGlobals.h @@ -0,0 +1,38 @@ +#pragma once + +#include "SdCardFontSystem.h" + +class GfxRenderer; + +// Global SD card font system instance (defined in main.cpp). +extern SdCardFontSystem sdFontSystem; + +// Ensure the correct SD card font family is loaded for current settings. +// Defined in main.cpp; call before entering the reader or after settings change. +extern void ensureSdFontLoaded(); + +// Resolve the SD card font ID for the given family name. +// Returns 0 if no SD font with that family name is currently loaded. +// Free function (not stored as a callback in CrossPointSettings) so the linker +// can resolve it directly without runtime indirection. +int resolveSdCardFontId(const char* familyName); + +// Trampolines used by the dynamic font-family SettingInfo. They walk +// sdFontSystem's registry on each call to translate between +// (built-in index | built-in count + SD index) and the appropriate +// CrossPointSettings field (fontFamily vs sdFontFamilyName). +// Signatures match SettingInfo::ValueGetterFn / ValueSetterFn so they can +// be wired directly into a DynamicEnum SettingInfo without further indirection. +uint8_t fontFamilyDynamicGetter(const void* ctx); +void fontFamilyDynamicSetter(void* ctx, uint8_t value); + +// Returns the total number of font-family options currently available +// (BUILTIN_FONT_COUNT + number of discovered SD families). Used by the +// settings UI / web layer to enrich enumLabels and to bound cycling. +uint8_t fontFamilyOptionCount(); + +// Returns the localized label for option index `i`. For built-in indices +// (< BUILTIN_FONT_COUNT) this returns the I18N string; for SD indices it +// returns the family name from sdFontSystem.registry(). +#include +std::string fontFamilyOptionLabel(uint8_t i); diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp new file mode 100644 index 0000000000..e471c39508 --- /dev/null +++ b/src/SdCardFontSystem.cpp @@ -0,0 +1,160 @@ +#include "SdCardFontSystem.h" + +#include +#include +#include + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "SdCardFontGlobals.h" + +// Free-function resolver used by CrossPointSettings::getReaderFontId(). +// Resolved by the linker — no callback indirection stored in settings. +int resolveSdCardFontId(const char* familyName) { return sdFontSystem.resolveFontId(familyName, 0); } + +// --- Font-family dynamic SettingInfo trampolines --- +// +// The font-family SettingInfo lives in a namespace-static SettingsList that is +// initialized at global-static phase, well before sdFontSystem.begin() runs. +// We therefore cannot bake the SD family list into the SettingInfo at +// construction; instead the SettingInfo holds these stateless trampolines that +// consult sdFontSystem at every call. enumLabels is enriched lazily by the +// consumers (SettingsActivity, CrossPointWebServer) before each iteration. + +uint8_t fontFamilyDynamicGetter(const void* /*ctx*/) { + if (SETTINGS.sdFontFamilyName[0] != '\0') { + const auto& families = sdFontSystem.registry().getFamilies(); + for (size_t i = 0; i < families.size(); i++) { + if (families[i].name == SETTINGS.sdFontFamilyName) { + return static_cast(CrossPointSettings::BUILTIN_FONT_COUNT + i); + } + } + // SD family no longer present (card removed?); fall through to built-in. + } + return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily + : CrossPointSettings::BOOKERLY; +} + +void fontFamilyDynamicSetter(void* /*ctx*/, uint8_t value) { + if (value < CrossPointSettings::BUILTIN_FONT_COUNT) { + SETTINGS.fontFamily = value; + SETTINGS.sdFontFamilyName[0] = '\0'; + return; + } + const auto& families = sdFontSystem.registry().getFamilies(); + uint8_t sdIdx = value - CrossPointSettings::BUILTIN_FONT_COUNT; + if (sdIdx < families.size()) { + strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1); + SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0'; + } +} + +uint8_t fontFamilyOptionCount() { + return static_cast(CrossPointSettings::BUILTIN_FONT_COUNT + sdFontSystem.registry().getFamilies().size()); +} + +std::string fontFamilyOptionLabel(uint8_t i) { + if (i < CrossPointSettings::BUILTIN_FONT_COUNT) { + static const StrId BUILTIN_LABELS[] = {StrId::STR_BOOKERLY, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}; + return I18N.get(BUILTIN_LABELS[i]); + } + const auto& families = sdFontSystem.registry().getFamilies(); + uint8_t sdIdx = i - CrossPointSettings::BUILTIN_FONT_COUNT; + return sdIdx < families.size() ? families[sdIdx].name : std::string(); +} + +// Map fontSize enum (SMALL=0, MEDIUM=1, LARGE=2, EXTRA_LARGE=3) to point sizes. +static constexpr uint8_t FONT_SIZE_TO_PT[] = {12, 14, 16, 18}; + +static uint8_t targetPtSizeFromSettings() { + uint8_t e = SETTINGS.fontSize; + if (e >= sizeof(FONT_SIZE_TO_PT)) e = 1; // default to MEDIUM + return FONT_SIZE_TO_PT[e]; +} + +void SdCardFontSystem::begin(GfxRenderer& renderer) { + registry_.discover(); + + // If user has a saved SD font selection, load it + if (SETTINGS.sdFontFamilyName[0] != '\0') { + const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); + if (family) { + if (manager_.loadFamily(*family, renderer, targetPtSizeFromSettings())) { + LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName); + } else { + LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName); + SETTINGS.sdFontFamilyName[0] = '\0'; + } + } else { + LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName); + SETTINGS.sdFontFamilyName[0] = '\0'; + } + } + + LOG_DBG("SDFS", "SD font system ready (%d families discovered)", registry_.getFamilyCount()); +} + +void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { + const char* wantedFamily = SETTINGS.sdFontFamilyName; + const std::string& currentFamily = manager_.currentFamilyName(); + const uint8_t targetPt = targetPtSizeFromSettings(); + + if (wantedFamily[0] == '\0') { + if (!currentFamily.empty()) { + manager_.unloadAll(renderer); + } + return; + } + + // Reload if family changed OR if the user-selected size changed and the + // family has a closer file than what's currently loaded. + bool familyMatches = (currentFamily == wantedFamily); + if (familyMatches) { + const auto* family = registry_.findFamily(wantedFamily); + if (!family) { + LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily); + manager_.unloadAll(renderer); + SETTINGS.sdFontFamilyName[0] = '\0'; + return; + } + uint8_t bestPt = 0; + int bestDiff = INT32_MAX; + for (const auto& f : family->files) { + int diff = abs(static_cast(f.pointSize) - static_cast(targetPt)); + if (diff < bestDiff) { + bestDiff = diff; + bestPt = f.pointSize; + } + } + if (bestPt == manager_.currentPointSize()) return; // already loaded with the right size + LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)", wantedFamily, manager_.currentPointSize(), bestPt, + targetPt); + } + + if (!currentFamily.empty()) { + manager_.unloadAll(renderer); + } + + const auto* family = registry_.findFamily(wantedFamily); + if (family) { + if (manager_.loadFamily(*family, renderer, targetPt)) { + LOG_DBG("SDFS", "Loaded SD font family: %s", wantedFamily); + } else { + LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily); + SETTINGS.sdFontFamilyName[0] = '\0'; + } + } else { + LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily); + SETTINGS.sdFontFamilyName[0] = '\0'; + } +} + +int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*fontSizeEnum*/) const { + // The manager loads exactly one size (closest to SETTINGS.fontSize), so the + // enum is implicit — always return the single loaded font ID for this family. + // ensureLoaded() must have been called with the current settings before this. + return manager_.getFontId(familyName); +} diff --git a/src/SdCardFontSystem.h b/src/SdCardFontSystem.h new file mode 100644 index 0000000000..3a4976897a --- /dev/null +++ b/src/SdCardFontSystem.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +class GfxRenderer; + +/// Facade that owns the SD card font registry, manager, and resolver logic. +/// Hides implementation details behind a single begin() + ensureLoaded() API. +class SdCardFontSystem { + public: + SdCardFontSystem() = default; + SdCardFontSystem(const SdCardFontSystem&) = delete; + SdCardFontSystem& operator=(const SdCardFontSystem&) = delete; + /// Discover SD card fonts and load user's saved selection. Call once during setup. + void begin(GfxRenderer& renderer); + + /// Ensure the correct SD font family is loaded for the current settings. + /// Call before entering the reader or after settings change. + void ensureLoaded(GfxRenderer& renderer); + + /// Resolve an SD card font ID from family name + fontSize enum. + /// Returns 0 if not found. Used by CrossPointSettings::getReaderFontId(). + int resolveFontId(const char* familyName, uint8_t fontSizeEnum) const; + + /// Access the registry (e.g. for settings UI to enumerate available fonts). + const SdCardFontRegistry& registry() const { return registry_; } + + private: + SdCardFontRegistry registry_; + SdCardFontManager manager_; +}; diff --git a/src/SettingsList.h b/src/SettingsList.h index dfb58a0d3a..e53acd9601 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -6,6 +6,7 @@ #include "CrossPointSettings.h" #include "KOReaderCredentialStore.h" +#include "SdCardFontGlobals.h" #include "activities/settings/SettingInfo.h" // Shared settings list used by both the device settings UI and the web settings API. @@ -86,10 +87,13 @@ inline const std::vector list = { SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation, {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, "orientation", StrId::STR_CAT_READER), - // Font - SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily, - {StrId::STR_BOOKERLY, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily", - StrId::STR_CAT_READER) + // Font — DynamicEnum so SD card font families can be appended at the consumer + // side (SettingsActivity / CrossPointWebServer enrich enumLabels before + // iterating). The built-in StrIds are kept as a fallback for code paths that + // don't enrich enumLabels. + SettingInfo::DynamicEnum(StrId::STR_FONT_FAMILY, + {StrId::STR_BOOKERLY, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, + fontFamilyDynamicGetter, fontFamilyDynamicSetter, "fontFamily", StrId::STR_CAT_READER) .withSubcategory(StrId::STR_MENU_READER_FONT), SettingInfo::Enum(StrId::STR_FONT_SIZE, &CrossPointSettings::fontSize, {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE}, "fontSize", diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 5fe41c90ae..6d9ccbb931 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -9,6 +9,7 @@ #include "CrossPointState.h" #include "OpdsServerStore.h" +#include "SdCardFontGlobals.h" #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" @@ -282,6 +283,7 @@ void ActivityManager::goToBrowser() { } void ActivityManager::goToReader(std::string path) { + ensureSdFontLoaded(); replaceActivity(std::make_unique(renderer, mappedInput, std::move(path))); } @@ -301,6 +303,7 @@ void ActivityManager::goToKOReaderSync() { void ActivityManager::replaceWithReader(std::string path, ReturnHint hint) { returnHint = std::move(hint); hasReturnHint = true; + ensureSdFontLoaded(); replaceActivity(std::make_unique(renderer, mappedInput, std::move(path))); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3785cbb2e2..348429a959 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1073,48 +1073,25 @@ uint8_t EpubReaderActivity::getEffectiveImageRendering() const { } int EpubReaderActivity::getEffectiveReaderFontId() const { - const uint8_t fontFamily = - (bookFontFamilyOverride >= 0) ? static_cast(bookFontFamilyOverride) : SETTINGS.fontFamily; + // Per-book font override: when set, force a specific BUILT-IN family even if + // an SD card font is the global default. This makes the override predictable + // ("override forces back to a known built-in") and avoids surprising users + // who set the override before they had any SD fonts. const uint8_t fontSize = (bookFontSizeOverride >= 0) ? static_cast(bookFontSizeOverride) : SETTINGS.fontSize; - switch (fontFamily) { - case CrossPointSettings::NOTOSANS: - switch (fontSize) { - case CrossPointSettings::SMALL: - return NOTOSANS_12_FONT_ID; - case CrossPointSettings::MEDIUM: - default: - return NOTOSANS_14_FONT_ID; - case CrossPointSettings::LARGE: - return NOTOSANS_16_FONT_ID; - case CrossPointSettings::EXTRA_LARGE: - return NOTOSANS_18_FONT_ID; - } - case CrossPointSettings::OPENDYSLEXIC: - switch (fontSize) { - case CrossPointSettings::SMALL: - return OPENDYSLEXIC_8_FONT_ID; - case CrossPointSettings::MEDIUM: - default: - return OPENDYSLEXIC_10_FONT_ID; - case CrossPointSettings::LARGE: - return OPENDYSLEXIC_12_FONT_ID; - case CrossPointSettings::EXTRA_LARGE: - return OPENDYSLEXIC_14_FONT_ID; - } - case CrossPointSettings::BOOKERLY: - default: - switch (fontSize) { - case CrossPointSettings::SMALL: - return BOOKERLY_12_FONT_ID; - case CrossPointSettings::MEDIUM: - default: - return BOOKERLY_14_FONT_ID; - case CrossPointSettings::LARGE: - return BOOKERLY_16_FONT_ID; - case CrossPointSettings::EXTRA_LARGE: - return BOOKERLY_18_FONT_ID; - } + if (bookFontFamilyOverride >= 0) { + return CrossPointSettings::getBuiltinReaderFontId(static_cast(bookFontFamilyOverride), fontSize); + } + // No override: defer to global resolution (which honors SD card font selection). + // We synthesize a temporary lookup using the override fontSize if it's set; otherwise + // SETTINGS.getReaderFontId() is the canonical answer. + if (bookFontSizeOverride >= 0) { + if (SETTINGS.sdFontFamilyName[0] != '\0') { + // SD font selected globally — size override doesn't change which family resolves. + return SETTINGS.getReaderFontId(); + } + return CrossPointSettings::getBuiltinReaderFontId(SETTINGS.fontFamily, fontSize); } + return SETTINGS.getReaderFontId(); } bool EpubReaderActivity::stepPageState(const bool isForwardTurn) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b8e3ffbab8..7415af40ac 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,6 +6,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "SettingActionDispatch.h" @@ -66,14 +68,23 @@ void SettingsActivity::onEnter() { setting.nameId == StrId::STR_TIMEZONE)) { continue; } - if (setting.category == StrId::STR_CAT_DISPLAY) { - addTo(displaySettings, lastDisplaySub, setting); - } else if (setting.category == StrId::STR_CAT_READER) { - addTo(readerSettings, lastReaderSub, setting); - } else if (setting.category == StrId::STR_CAT_CONTROLS) { - addTo(controlsSettings, lastControlsSub, setting); - } else if (setting.category == StrId::STR_CAT_SYSTEM) { - addTo(systemSettings, lastSystemSub, setting); + // Enrich the font-family entry with SD card families discovered at boot. + // The list itself is a namespace-static; we only mutate our local copy here. + SettingInfo enriched = setting; + if (setting.key && std::strcmp(setting.key, "fontFamily") == 0) { + const uint8_t n = fontFamilyOptionCount(); + enriched.enumLabels.clear(); + enriched.enumLabels.reserve(n); + for (uint8_t i = 0; i < n; i++) enriched.enumLabels.push_back(fontFamilyOptionLabel(i)); + } + if (enriched.category == StrId::STR_CAT_DISPLAY) { + addTo(displaySettings, lastDisplaySub, enriched); + } else if (enriched.category == StrId::STR_CAT_READER) { + addTo(readerSettings, lastReaderSub, enriched); + } else if (enriched.category == StrId::STR_CAT_CONTROLS) { + addTo(controlsSettings, lastControlsSub, enriched); + } else if (enriched.category == StrId::STR_CAT_SYSTEM) { + addTo(systemSettings, lastSystemSub, enriched); } // Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI } diff --git a/src/main.cpp b/src/main.cpp index 90f500c8fc..d84a64e49a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "RecentBooksStore.h" +#include "SdCardFontSystem.h" #include "WeatherSettingsStore.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" @@ -40,7 +41,8 @@ ButtonEventManager& globalButtonEvents() { return buttonEventManager; } GfxRenderer renderer(display); ActivityManager activityManager(renderer, mappedInputManager); FontDecompressor fontDecompressor; -FontCacheManager fontCacheManager(renderer.getFontMap()); +SdCardFontSystem sdFontSystem; +FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts()); // Fonts EpdFont bookerly14RegularFont(&bookerly_14_regular); @@ -182,9 +184,18 @@ void setupDisplayAndFonts() { renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily); + + // Discover SD card fonts (under /.crosspoint/fonts/) and load the family + // currently selected in settings (if any). Safe to call without an SD card. + sdFontSystem.begin(renderer); + LOG_DBG("MAIN", "Fonts setup"); } +// Defined here to satisfy SdCardFontGlobals.h's extern declaration. Keeps +// activity-side callers out of SdCardFontSystem internals. +void ensureSdFontLoaded() { sdFontSystem.ensureLoaded(renderer); } + void setup() { { esp_ota_img_states_t otaState; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 29762b997a..0feddf8c2a 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -9,6 +9,7 @@ #include #include +#include #include "CrossPointSettings.h" #include "OpdsServerStore.h" @@ -1224,8 +1225,21 @@ void CrossPointWebServer::handleGetSettings() const { bool seenFirst = false; JsonDocument doc; - for (const auto& s : settings) { - if (!s.key) continue; // Skip ACTION-only entries + for (const auto& sBase : settings) { + if (!sBase.key) continue; // Skip ACTION-only entries + + // Enrich the font-family entry with current SD card families. + SettingInfo sLocal; + const SettingInfo* sPtr = &sBase; + if (std::strcmp(sBase.key, "fontFamily") == 0) { + sLocal = sBase; + const uint8_t n = fontFamilyOptionCount(); + sLocal.enumLabels.clear(); + sLocal.enumLabels.reserve(n); + for (uint8_t i = 0; i < n; i++) sLocal.enumLabels.push_back(fontFamilyOptionLabel(i)); + sPtr = &sLocal; + } + const SettingInfo& s = *sPtr; doc.clear(); doc["key"] = s.key; @@ -1337,7 +1351,11 @@ void CrossPointWebServer::handlePostSettings() { } case SettingType::ENUM: { const int val = doc[s.key].as(); - const auto count = static_cast(s.enumLabels.empty() ? s.enumValues.size() : s.enumLabels.size()); + // For fontFamily the enumLabels in the static list are empty by design + // (built lazily by handleGetSettings); use the dynamic option count instead. + const int count = (std::strcmp(s.key, "fontFamily") == 0) + ? static_cast(fontFamilyOptionCount()) + : static_cast(s.enumLabels.empty() ? s.enumValues.size() : s.enumLabels.size()); if (val >= 0 && val < count) { if (s.valuePtr) { SETTINGS.*(s.valuePtr) = static_cast(val); From 4c8e4e2d058230e7619f68374487f32242d05d2d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 15:12:11 +0200 Subject: [PATCH 02/13] Improve pre warm caching --- lib/EpdFont/SdCardFont.cpp | 354 ++++++++++++++++--- lib/EpdFont/SdCardFont.h | 26 ++ lib/GfxRenderer/GfxRenderer.cpp | 6 + lib/GfxRenderer/GfxRenderer.h | 4 + src/activities/reader/EpubReaderActivity.cpp | 6 + 5 files changed, 351 insertions(+), 45 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 53a20fdf8f..38081970df 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -50,6 +50,8 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) { s.miniBitmap = nullptr; s.miniIntervalCount = 0; s.miniGlyphCount = 0; + s.miniMode = PerStyle::MiniMode::NONE; + s.reportedMissCount = 0; freeStyleMiniKern(s); memset(&s.miniData, 0, sizeof(s.miniData)); s.epdFont.data = &s.stubData; @@ -667,30 +669,183 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn return totalMissed; } +// Returns true iff every codepoint in `codepoints[0..cpCount)` is already covered +// by the style's current miniIntervals. O(cpCount * log intervalCount). +bool SdCardFont::allCpsCovered(const PerStyle& s, const uint32_t* codepoints, uint32_t cpCount) { + if (s.miniIntervalCount == 0) return false; + for (uint32_t i = 0; i < cpCount; i++) { + const uint32_t cp = codepoints[i]; + // Binary search miniIntervals for the range containing cp. + int lo = 0, hi = static_cast(s.miniIntervalCount) - 1; + bool found = false; + while (lo <= hi) { + int mid = (lo + hi) / 2; + const auto& iv = s.miniIntervals[mid]; + if (cp < iv.first) { + hi = mid - 1; + } else if (cp > iv.last) { + lo = mid + 1; + } else { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { auto& s = styles_[styleIdx]; + // ---- Fast-path coverage check ---- + // If the existing cache already covers all requested cps in a compatible mode, + // there's nothing to do. This is the dominant case during pagination after the + // first paragraph has populated the metadata cache. + if (s.miniMode != PerStyle::MiniMode::NONE && allCpsCovered(s, codepoints, cpCount)) { + // For metadata-only calls, METADATA or FULL cache both satisfy layout queries. + // For full (bitmap) calls, only FULL satisfies — METADATA lacks bitmap data. + if (metadataOnly || s.miniMode == PerStyle::MiniMode::FULL) { + // Already wired into miniData; nothing else to do. + return 0; + } + } + + // ---- Decide merge vs rebuild ---- + // Merge mode: extend the existing METADATA cache with the new cps without + // re-reading already-loaded glyphs. Only safe when: + // - request is metadata-only (no bitmap data needed) + // - existing mode is METADATA + // - merged total stays within MAX_PAGE_GLYPHS + // In all other cases we fall back to today's full-rebuild behavior (which is + // also what happens for full bitmap prewarms — those are page-scoped). + const bool tryMerge = + metadataOnly && s.miniMode == PerStyle::MiniMode::METADATA && s.miniGlyphs != nullptr && s.miniGlyphCount > 0; + // Map codepoints to global glyph indices for this style struct CpGlyphMapping { uint32_t codepoint; int32_t globalIndex; }; - CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[cpCount]; + + // Worst-case allocation: existing + new (for the merge path) or just new. + const uint32_t mappingCapacity = tryMerge ? (s.miniGlyphCount + cpCount) : cpCount; + CpGlyphMapping* mappings = new (std::nothrow) CpGlyphMapping[mappingCapacity]; if (!mappings) { LOG_ERR("SDCF", "Failed to allocate mapping array for style %u", styleIdx); return static_cast(cpCount); } uint32_t validCount = 0; + + // For merge: seed mappings with already-loaded cps + their global indices, + // recovered by walking miniIntervals and re-resolving via fullIntervals. + // (We don't store globalIndex per mini-glyph; recomputing it is a binary + // search per loaded cp, cheap relative to SD I/O.) + if (tryMerge) { + for (uint32_t iv = 0; iv < s.miniIntervalCount; iv++) { + const auto& interval = s.miniIntervals[iv]; + for (uint32_t cp = interval.first; cp <= interval.last; cp++) { + int32_t gIdx = findGlobalGlyphIndex(s, cp); + if (gIdx < 0) continue; // shouldn't happen; defensive + mappings[validCount].codepoint = cp; + mappings[validCount].globalIndex = gIdx; + validCount++; + } + } + } + + // Mark where the existing-glyph block ends; new cps follow. + const uint32_t existingCount = validCount; + + // Add new requested cps that aren't already in the existing block. for (uint32_t i = 0; i < cpCount; i++) { - int32_t idx = findGlobalGlyphIndex(s, codepoints[i]); + const uint32_t cp = codepoints[i]; + + // Skip cps already in the existing block (only relevant in merge mode). + if (existingCount > 0) { + bool dup = false; + // Existing cps are sorted; binary search. + int lo = 0, hi = static_cast(existingCount) - 1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (mappings[mid].codepoint < cp) { + lo = mid + 1; + } else if (mappings[mid].codepoint > cp) { + hi = mid - 1; + } else { + dup = true; + break; + } + } + if (dup) continue; + } + + int32_t idx = findGlobalGlyphIndex(s, cp); if (idx >= 0) { - mappings[validCount].codepoint = codepoints[i]; + if (validCount >= MAX_PAGE_GLYPHS) { + LOG_DBG("SDCF", "Cumulative cap (%u glyphs) reached for style %u; dropping merge cache", MAX_PAGE_GLYPHS, + styleIdx); + // Soft cap: discard the accumulated cache and fall back to rebuilding + // with just the new request set. Caller's text will be covered; + // already-loaded but no-longer-requested cps are lost. + delete[] mappings; + freeStyleMiniData(s); + // Recurse with rebuild semantics by clearing tryMerge state and trying + // again — implemented as inline reset below. + return prewarmStyle(styleIdx, codepoints, cpCount, metadataOnly); + } + mappings[validCount].codepoint = cp; mappings[validCount].globalIndex = idx; validCount++; } } - int missed = static_cast(cpCount - validCount); + // Sort the full mappings array by codepoint (existing block is already sorted, + // so std::sort on a partially sorted array is fast in practice; insertion-sort + // would be optimal but std::sort is fine for our sizes). + if (validCount > 0) { + std::sort(mappings, mappings + validCount, + [](const CpGlyphMapping& a, const CpGlyphMapping& b) { return a.codepoint < b.codepoint; }); + } + + // Count cps that are *newly* missing this accumulation cycle (i.e. not present + // in mappings[] AND not previously reported). Already-reported misses are + // suppressed so the same 4 special chars don't spam the log every paragraph. + // `mappings[]` is sorted by codepoint after the std::sort above, enabling + // O(log validCount) lookup per requested cp. + int missed = 0; + for (uint32_t i = 0; i < cpCount; i++) { + const uint32_t cp = codepoints[i]; + int lo = 0, hi = static_cast(validCount) - 1; + bool found = false; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (mappings[mid].codepoint < cp) { + lo = mid + 1; + } else if (mappings[mid].codepoint > cp) { + hi = mid - 1; + } else { + found = true; + break; + } + } + if (found) continue; + + // cp wasn't resolved. Check if we've already reported it this cycle. + bool alreadyReported = false; + for (uint8_t r = 0; r < s.reportedMissCount; r++) { + if (s.reportedMisses[r] == cp) { + alreadyReported = true; + break; + } + } + if (alreadyReported) continue; + + if (s.reportedMissCount < PerStyle::MAX_REPORTED_MISSES) { + s.reportedMisses[s.reportedMissCount++] = cp; + } + missed++; + } if (validCount == 0) { freeStyleMiniData(s); @@ -699,13 +854,29 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 return missed; } - // Build mini intervals from sorted codepoints + // Stash the old miniGlyphs so we can copy already-loaded entries by codepoint + // before freeing them. (For merge path; nullptr when not merging.) + EpdGlyph* oldGlyphs = tryMerge ? s.miniGlyphs : nullptr; + EpdUnicodeInterval* oldIntervals = tryMerge ? s.miniIntervals : nullptr; + uint32_t oldIntervalCount = tryMerge ? s.miniIntervalCount : 0; + // Detach so freeStyleMiniData doesn't delete them yet. + if (tryMerge) { + s.miniIntervals = nullptr; + s.miniGlyphs = nullptr; + s.miniIntervalCount = 0; + s.miniGlyphCount = 0; + } + + // freeStyleMiniData wipes everything, including miniBitmap (which is nullptr + // in metadata mode anyway). Safe in either path. freeStyleMiniData(s); uint32_t intervalCapacity = validCount; s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity]; if (!s.miniIntervals) { LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); + delete[] oldGlyphs; + delete[] oldIntervals; delete[] mappings; return static_cast(cpCount); } @@ -727,65 +898,138 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount]; if (!s.miniGlyphs) { LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); + delete[] oldGlyphs; + delete[] oldIntervals; delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); } - // Build sorted read order for sequential I/O - uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; - if (!readOrder) { - LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); + // Build a tracking array of which mappings still need SD I/O. For the merge + // path, copy already-loaded glyph metadata from oldGlyphs first; remaining + // entries get read from SD below. + bool* needsRead = new (std::nothrow) bool[validCount]; + if (!needsRead) { + LOG_ERR("SDCF", "Failed to allocate needsRead array for style %u", styleIdx); + delete[] oldGlyphs; + delete[] oldIntervals; delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); } - for (uint32_t i = 0; i < validCount; i++) readOrder[i] = i; - std::sort(readOrder, readOrder + validCount, - [&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; }); + for (uint32_t i = 0; i < validCount; i++) needsRead[i] = true; - FsFile file; - if (!Storage.openFileForRead("SDCF", filePath_, file)) { - LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx); - delete[] readOrder; - delete[] mappings; - freeStyleMiniData(s); - return static_cast(cpCount); + if (tryMerge && oldGlyphs && oldIntervals) { + // For each cp in the new merged set, look it up in the old miniIntervals to + // see if we already have its EpdGlyph. If yes, copy it over and skip the read. + for (uint32_t i = 0; i < validCount; i++) { + const uint32_t cp = mappings[i].codepoint; + // Binary search oldIntervals for cp. + int lo = 0, hi = static_cast(oldIntervalCount) - 1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + const auto& iv = oldIntervals[mid]; + if (cp < iv.first) { + hi = mid - 1; + } else if (cp > iv.last) { + lo = mid + 1; + } else { + s.miniGlyphs[i] = oldGlyphs[iv.offset + (cp - iv.first)]; + needsRead[i] = false; + break; + } + } + } } - unsigned long sdStart = millis(); - uint32_t seekCount = 0; + delete[] oldGlyphs; + delete[] oldIntervals; - // Read glyph metadata. lastReadIndex tracks sequential reads to skip redundant - // seeks; INT32_MIN guarantees the first iteration always seeks to the correct - // offset (otherwise when gIdx == 0, the "gIdx != lastReadIndex + 1" check would - // be false and we'd read from the file's current position — the header — which - // decodes to a garbage EpdGlyph with a massive advanceX, inflating any word - // containing that codepoint beyond page width). - int32_t lastReadIndex = INT32_MIN; + // Count how many SD reads are still needed, and build a sorted read order. + uint32_t toReadCount = 0; for (uint32_t i = 0; i < validCount; i++) { - uint32_t mapIdx = readOrder[i]; - int32_t gIdx = mappings[mapIdx].globalIndex; + if (needsRead[i]) toReadCount++; + } - uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); - if (gIdx != lastReadIndex + 1) { - file.seekSet(fileOff); - seekCount++; + uint32_t* readOrder = nullptr; + if (toReadCount > 0) { + readOrder = new (std::nothrow) uint32_t[toReadCount]; + if (!readOrder) { + LOG_ERR("SDCF", "Failed to allocate read order for style %u", styleIdx); + delete[] needsRead; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); } - if (file.read(reinterpret_cast(&s.miniGlyphs[mapIdx]), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { - LOG_ERR("SDCF", "Prewarm: short glyph read (style %u, glyph %d)", styleIdx, gIdx); - file.close(); + uint32_t k = 0; + for (uint32_t i = 0; i < validCount; i++) { + if (needsRead[i]) readOrder[k++] = i; + } + std::sort(readOrder, readOrder + toReadCount, + [&](uint32_t a, uint32_t b) { return mappings[a].globalIndex < mappings[b].globalIndex; }); + } + + unsigned long sdStart = millis(); + uint32_t seekCount = 0; + FsFile file; + + if (toReadCount > 0) { + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to reopen .cpfont for prewarm (style %u)", styleIdx); delete[] readOrder; + delete[] needsRead; delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); } - lastReadIndex = gIdx; + + // Read glyph metadata. lastReadIndex tracks sequential reads to skip redundant + // seeks; INT32_MIN guarantees the first iteration always seeks to the correct + // offset (otherwise when gIdx == 0, the "gIdx != lastReadIndex + 1" check would + // be false and we'd read from the file's current position — the header — which + // decodes to a garbage EpdGlyph with a massive advanceX, inflating any word + // containing that codepoint beyond page width). + int32_t lastReadIndex = INT32_MIN; + for (uint32_t i = 0; i < toReadCount; i++) { + uint32_t mapIdx = readOrder[i]; + int32_t gIdx = mappings[mapIdx].globalIndex; + + uint32_t fileOff = s.glyphsFileOffset + static_cast(gIdx) * sizeof(EpdGlyph); + if (gIdx != lastReadIndex + 1) { + file.seekSet(fileOff); + seekCount++; + } + if (file.read(reinterpret_cast(&s.miniGlyphs[mapIdx]), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { + LOG_ERR("SDCF", "Prewarm: short glyph read (style %u, glyph %d)", styleIdx, gIdx); + file.close(); + delete[] readOrder; + delete[] needsRead; + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + lastReadIndex = gIdx; + } } + delete[] needsRead; + delete[] readOrder; + readOrder = nullptr; uint32_t totalBitmapSize = 0; if (!metadataOnly) { + // Full render prewarm always reads bitmap data for every glyph in the + // mini set. The metadata-pass file open above only covered cps that + // needed metadata reads — open the file now if it isn't already. + if (!file) { + if (!Storage.openFileForRead("SDCF", filePath_, file)) { + LOG_ERR("SDCF", "Failed to reopen .cpfont for bitmap prewarm (style %u)", styleIdx); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + } + // Compute total bitmap size for (uint32_t i = 0; i < validCount; i++) { totalBitmapSize += s.miniGlyphs[i].dataLength; @@ -795,20 +1039,29 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 if (!s.miniBitmap) { LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); file.close(); - delete[] readOrder; delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); } - // Read bitmap data sorted by file offset - std::sort(readOrder, readOrder + validCount, + // Allocate a fresh readOrder covering all validCount glyphs, sorted by + // bitmap file offset for sequential I/O. + uint32_t* bitmapOrder = new (std::nothrow) uint32_t[validCount]; + if (!bitmapOrder) { + LOG_ERR("SDCF", "Failed to allocate bitmap read order for style %u", styleIdx); + file.close(); + delete[] mappings; + freeStyleMiniData(s); + return static_cast(cpCount); + } + for (uint32_t i = 0; i < validCount; i++) bitmapOrder[i] = i; + std::sort(bitmapOrder, bitmapOrder + validCount, [&](uint32_t a, uint32_t b) { return s.miniGlyphs[a].dataOffset < s.miniGlyphs[b].dataOffset; }); uint32_t miniBitmapOffset = 0; uint32_t lastBitmapEnd = UINT32_MAX; for (uint32_t i = 0; i < validCount; i++) { - uint32_t mapIdx = readOrder[i]; + uint32_t mapIdx = bitmapOrder[i]; EpdGlyph& glyph = s.miniGlyphs[mapIdx]; if (glyph.dataLength == 0) { @@ -824,7 +1077,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 if (file.read(s.miniBitmap + miniBitmapOffset, glyph.dataLength) != static_cast(glyph.dataLength)) { LOG_ERR("SDCF", "Prewarm: short bitmap read (style %u)", styleIdx); file.close(); - delete[] readOrder; + delete[] bitmapOrder; delete[] mappings; freeStyleMiniData(s); return static_cast(cpCount); @@ -834,11 +1087,11 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 glyph.dataOffset = miniBitmapOffset; miniBitmapOffset += glyph.dataLength; } + delete[] bitmapOrder; } uint32_t sdTime = millis() - sdStart; - file.close(); - delete[] readOrder; + if (file) file.close(); delete[] mappings; // Full render prewarm: load the persistent kern classes + ligatures (one-time @@ -870,6 +1123,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 s.miniData.glyphMissCtx = &overflowCtx_[styleIdx]; s.epdFont.data = &s.miniData; + s.miniMode = metadataOnly ? PerStyle::MiniMode::METADATA : PerStyle::MiniMode::FULL; // Accumulate stats stats_.sdReadTimeMs += sdTime; @@ -891,6 +1145,16 @@ void SdCardFont::clearCache() { } } +void SdCardFont::clearAccumulation() { + // Same as clearCache() but skips the overflow ring buffer (per-glyph on-demand + // loads are independent of the cumulative metadata cache). + for (uint8_t i = 0; i < MAX_STYLES; i++) { + if (!styles_[i].present) continue; + freeStyleMiniData(styles_[i]); + applyGlyphMissCallback(i); + } +} + // --- Stats --- void SdCardFont::logStats(const char* label) { diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 084ad97c7b..e21f5c3439 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -28,6 +28,12 @@ class SdCardFont { // Free mini data for all styles, restore stub EpdFontData. void clearCache(); + // Soft cache reset: drop the cumulative metadata-only prewarm cache built + // up by repeated layout-time ensureSdCardFontReady() calls. Bitmap-mode + // (FULL) caches are also dropped. Call this between sections to bound the + // cumulative cp set growth across pagination. + void clearAccumulation(); + // Returns pointer to the managed EpdFont for a given style. // Returns nullptr if the style is not present. EpdFont* getEpdFont(uint8_t style = 0); @@ -117,6 +123,25 @@ class SdCardFont { uint32_t miniIntervalCount = 0; uint32_t miniGlyphCount = 0; + // Cache mode for the current mini buffers: + // NONE = empty / freshly cleared, no glyphs loaded + // METADATA = miniGlyphs has glyph metrics only (no bitmap data); built + // up incrementally across paragraph-level ensureSdCardFontReady + // calls during pagination. Safe to merge new cps into. + // FULL = miniGlyphs + miniBitmap loaded, page-scoped (built once per + // page render). Layout-only prewarm calls are no-ops in this + // mode (overflow handler covers any uncovered cp at draw time). + enum class MiniMode : uint8_t { NONE, METADATA, FULL }; + MiniMode miniMode = MiniMode::NONE; + + // Codepoints already reported as missing during the current accumulation + // cycle. Cleared by clearAccumulation() / freeStyleAll(). Bounded by + // MAX_REPORTED_MISSES; once full, further misses go unreported (the cps + // still cleanly fall back to the replacement glyph in EpdFont::getGlyph). + static constexpr uint8_t MAX_REPORTED_MISSES = 32; + uint32_t reportedMisses[MAX_REPORTED_MISSES] = {}; + uint8_t reportedMissCount = 0; + // Per-page mini kern matrix (built by buildMiniKernMatrix on each full // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints // used on the current page to renumbered class IDs (1..miniKern*ClassCount). @@ -166,6 +191,7 @@ class SdCardFont { bool loaded_ = false; // Per-style helpers + static bool allCpsCovered(const PerStyle& s, const uint32_t* codepoints, uint32_t cpCount); void freeStyleMiniData(PerStyle& s); void freeStyleAll(PerStyle& s); void freeStyleKernLigatureData(PerStyle& s); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b7032953fc..011f5a67a4 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -50,6 +50,12 @@ void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text) const } } +void GfxRenderer::clearSdCardFontAccumulation() const { + for (auto& [id, font] : sdCardFonts_) { + font->clearAccumulation(); + } +} + void GfxRenderer::begin() { frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 4cc6c6192b..76a536fe7b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -115,6 +115,10 @@ class GfxRenderer { // (which holds a const GfxRenderer&) before measuring word widths. Safe to call on // non-SD fonts (no-op). void ensureSdCardFontReady(int fontId, const char* utf8Text) const; + // Clear the cumulative SD card font metadata cache built up by repeated + // ensureSdCardFontReady() calls. Call between sections to bound the cumulative + // codepoint set growth across pagination. Safe to call when no SD font is active. + void clearSdCardFontAccumulation() const; // Orientation control (affects logical width/height and coordinate transforms) void setOrientation(const Orientation o) { orientation.store(static_cast(o), std::memory_order_relaxed); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 348429a959..b30e4a4e17 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1223,6 +1223,10 @@ void EpubReaderActivity::render(RenderLock&& lock) { GUI.fillPopupProgress(renderer, popupRect, progress); }; + // Reset cumulative SD font metadata cache so this section starts fresh. + // Pagination will rebuild only the cps it actually encounters, bounded + // by MAX_PAGE_GLYPHS per style. + renderer.clearSdCardFontAccumulation(); if (!section->createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering, @@ -1385,6 +1389,8 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW } LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex); + // Reset cumulative SD font metadata cache for the new section. + renderer.clearSdCardFontAccumulation(); if (!nextSection.createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { From 1195db1e18642d7555b83f2520785589a7196e82 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 15:47:51 +0200 Subject: [PATCH 03/13] Some review comments --- lib/EpdFont/SdCardFont.cpp | 24 ++++++++++++-- lib/EpdFont/SdCardFontManager.cpp | 10 +----- lib/EpdFont/SdCardFontRegistry.cpp | 15 +++++++++ lib/EpdFont/SdCardFontRegistry.h | 5 +++ lib/EpdFont/scripts/fontconvert_sdcard.py | 9 +++-- src/SdCardFontSystem.cpp | 11 ++----- .../reader/EpubReaderMenuActivity.cpp | 33 +++++++++++++++---- 7 files changed, 77 insertions(+), 30 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 38081970df..2d29a13e80 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -432,7 +432,14 @@ bool SdCardFont::load(const char* path) { return false; } - // Begin content hash: accumulate global header + // Begin content hash: accumulate global header. + // KNOWN LIMITATION: hash covers global header + per-style TOC only, not the + // payload sections (intervals / glyph metrics / kern / ligature / bitmap). A + // font edit that alters payload bytes without changing any TOC count would + // produce the same contentHash and could leave stale EPUB section caches. + // Acceptable in practice because generate-sd-fonts.sh regeneration almost + // always changes interval/glyph/kern counts; revisit if we see real-world + // mismatches. uint32_t hash = fnv1a(headerBuf, HEADER_SIZE); bool is2Bit = (readU16(headerBuf + 10) & 1) != 0; @@ -1214,7 +1221,12 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { EpdGlyph tempGlyph; uint32_t glyphFileOff = s.glyphsFileOffset + static_cast(globalIdx) * sizeof(EpdGlyph); - file.seekSet(glyphFileOff); + if (!file.seekSet(glyphFileOff)) { + LOG_ERR("SDCF", "Overflow: seek failed for glyph metadata U+%04X style %u", codepoint, styleIdx); + file.close(); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } if (file.read(reinterpret_cast(&tempGlyph), sizeof(EpdGlyph)) != sizeof(EpdGlyph)) { LOG_ERR("SDCF", "Overflow: failed to read glyph metadata for U+%04X style %u", codepoint, styleIdx); file.close(); @@ -1232,7 +1244,13 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { if (!wasAtCapacity) self->overflowCount_--; return nullptr; } - file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset); + if (!file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset)) { + LOG_ERR("SDCF", "Overflow: seek failed for bitmap U+%04X style %u", codepoint, styleIdx); + delete[] tempBitmap; + file.close(); + if (!wasAtCapacity) self->overflowCount_--; + return nullptr; + } if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { LOG_ERR("SDCF", "Overflow: failed to read bitmap for U+%04X", codepoint); delete[] tempBitmap; diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp index f6c51bfb6b..5b8a2da3d3 100644 --- a/lib/EpdFont/SdCardFontManager.cpp +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -39,15 +39,7 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender // Pick the single file whose size is closest to targetPtSize. Loading // only one size bounds resident memory (intervals + kern/ligature tables // per style) to one file's worth, vs. N_sizes × per-file overhead. - const SdCardFontFileInfo* selected = nullptr; - int bestDiff = INT32_MAX; - for (const auto& fileInfo : family.files) { - int diff = std::abs(static_cast(fileInfo.pointSize) - static_cast(targetPtSize)); - if (diff < bestDiff) { - bestDiff = diff; - selected = &fileInfo; - } - } + const SdCardFontFileInfo* selected = family.pickClosestSize(targetPtSize); if (!selected) { LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str()); return false; diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index 8a162f48a1..099732f2fb 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -38,6 +38,21 @@ std::vector SdCardFontFamilyInfo::availableSizes() const { return sizes; } +const SdCardFontFileInfo* SdCardFontFamilyInfo::pickClosestSize(uint8_t targetPtSize) const { + const SdCardFontFileInfo* selected = nullptr; + int bestDiff = INT32_MAX; + for (const auto& f : files) { + int diff = std::abs(static_cast(f.pointSize) - static_cast(targetPtSize)); + // Strict < ensures the first scan wins on ties; then tie-break by smaller + // pointSize to make the choice independent of filesystem enumeration order. + if (diff < bestDiff || (diff == bestDiff && selected && f.pointSize < selected->pointSize)) { + bestDiff = diff; + selected = &f; + } + } + return selected; +} + // --- SdCardFontRegistry --- bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) { diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index 941668ccb4..ee8c34bbc4 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -19,6 +19,11 @@ struct SdCardFontFamilyInfo { const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; bool hasSize(uint8_t size) const; std::vector availableSizes() const; + + // Pick the file whose pointSize is closest to targetPtSize. On ties (equal + // distance) prefers the smaller pointSize so behaviour is deterministic + // across SD card layouts. Returns nullptr when files is empty. + const SdCardFontFileInfo* pickClosestSize(uint8_t targetPtSize) const; }; class SdCardFontRegistry { diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index 3fa36d1ef7..8401515057 100644 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -451,6 +451,12 @@ def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=F style_label = style_names.get(style_id, str(style_id)) face = freetype.Face(fontfile) + # Set font size at 150 DPI (matching fontconvert.py) BEFORE any glyph load + # — load_glyph() with FT_LOAD_RENDER renders at the active size, so calling + # it before set_char_size() would waste work at the default size and risk + # Invalid_Size_Handle on some fonts. + face.set_char_size(size << 6, size << 6, 150, 150) + load_flags = freetype.FT_LOAD_RENDER if force_autohint: load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT @@ -480,9 +486,6 @@ def load_glyph(code_point): total_glyphs = sum(end - start + 1 for start, end in intervals) print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr) - # Set font size at 150 DPI (matching fontconvert.py) - face.set_char_size(size << 6, size << 6, 150, 150) - # Rasterize all glyphs total_bitmap_size = 0 all_glyphs = [] diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index e471c39508..35265b0989 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -120,15 +120,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { SETTINGS.sdFontFamilyName[0] = '\0'; return; } - uint8_t bestPt = 0; - int bestDiff = INT32_MAX; - for (const auto& f : family->files) { - int diff = abs(static_cast(f.pointSize) - static_cast(targetPt)); - if (diff < bestDiff) { - bestDiff = diff; - bestPt = f.pointSize; - } - } + const auto* best = family->pickClosestSize(targetPt); + const uint8_t bestPt = best ? best->pointSize : 0; if (bestPt == manager_.currentPointSize()) return; // already loaded with the right size LOG_DBG("SDFS", "Reloading %s: size %u -> %u (target %u)", wantedFamily, manager_.currentPointSize(), bestPt, targetPt); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index bb621e54fc..8f42e97e7a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -5,10 +5,31 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" +#include "SdCardFontGlobals.h" #include "activities/settings/SettingsSubmenuActivity.h" #include "components/UITheme.h" #include "fontIds.h" +namespace { +// Returns the localized name of the family currently used as the global default +// for the reader. When the user has selected an SD card font globally, the +// override menu's "Default" label should reflect that family by name even +// though the per-book override list itself is built-in only. +std::string defaultFontFamilyLabel(const SettingInfo& item) { + if (SETTINGS.sdFontFamilyName[0] != '\0') { + return std::string(SETTINGS.sdFontFamilyName); + } + // Built-in: enumValues[0] is STR_DEFAULT_VALUE, [1..3] are the three families + // in the same order as CrossPointSettings::FONT_FAMILY (BOOKERLY, NOTOSANS, + // OPENDYSLEXIC). + const auto idx = static_cast(SETTINGS.fontFamily + 1); + if (idx < item.enumValues.size()) { + return I18N.get(item.enumValues[idx]); + } + return {}; +} +} // namespace + EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, @@ -290,9 +311,9 @@ std::string EpubReaderMenuActivity::getItemValueString(int index) const { } } if (item.nameId == StrId::STR_FONT_FAMILY && pendingFontFamilyOverride < 0) { - const auto defaultIndex = static_cast(SETTINGS.fontFamily + 1); - if (defaultIndex < item.enumValues.size()) { - return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[defaultIndex]) + ")"; + const auto label = defaultFontFamilyLabel(item); + if (!label.empty()) { + return std::string(tr(STR_DEFAULT_VALUE)) + " (" + label + ")"; } } if (item.nameId == StrId::STR_FONT_SIZE && pendingFontSizeOverride < 0) { @@ -324,9 +345,9 @@ void EpubReaderMenuActivity::openSubmenu(const SettingInfo& submenuEntry) { } } if (item.nameId == StrId::STR_FONT_FAMILY && pendingFontFamilyOverride < 0) { - const auto valueIndex = static_cast(SETTINGS.fontFamily + 1); - if (valueIndex < item.enumValues.size()) { - return std::string(tr(STR_DEFAULT_VALUE)) + " (" + I18N.get(item.enumValues[valueIndex]) + ")"; + const auto label = defaultFontFamilyLabel(item); + if (!label.empty()) { + return std::string(tr(STR_DEFAULT_VALUE)) + " (" + label + ")"; } } if (item.nameId == StrId::STR_FONT_SIZE && pendingFontSizeOverride < 0) { From 239fb983c635cd8a5f6b1344b8e0975dc070e0b3 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 16:06:02 +0200 Subject: [PATCH 04/13] More review comments --- src/CrossPointSettings.cpp | 66 +++++++++----------- src/SdCardFontGlobals.h | 6 +- src/SdCardFontSystem.cpp | 20 ++++-- src/activities/ActivityManager.cpp | 2 + src/activities/reader/EpubReaderActivity.cpp | 59 ++++++++++++++--- src/activities/reader/EpubReaderActivity.h | 1 + 6 files changed, 101 insertions(+), 53 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 7d81e24745..880b42fdf8 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -264,52 +264,44 @@ bool CrossPointSettings::loadFromBinaryFile() { } float CrossPointSettings::getReaderLineCompression() const { - // SD card fonts inherit the Bookerly-style line compression (the most neutral - // values) since we have no per-family metadata for SD fonts. - if (sdFontFamilyName[0] != '\0') { + const int effectiveFontId = getReaderFontId(); + const int bookerlyId = getBuiltinReaderFontId(BOOKERLY, fontSize); + const int notosansId = getBuiltinReaderFontId(NOTOSANS, fontSize); + const int opendyslexicId = getBuiltinReaderFontId(OPENDYSLEXIC, fontSize); + + if (effectiveFontId == notosansId) { switch (lineSpacing) { case TIGHT: - return 0.95f; + return 0.90f; case NORMAL: default: + return 0.95f; + case WIDE: return 1.0f; + } + } + + if (effectiveFontId == opendyslexicId) { + switch (lineSpacing) { + case TIGHT: + return 0.90f; + case NORMAL: + default: + return 0.95f; case WIDE: - return 1.1f; + return 1.0f; } } - switch (fontFamily) { - case BOOKERLY: + // Bookerly or any SD card font: use the Bookerly-style neutral values. + switch (lineSpacing) { + case TIGHT: + return 0.95f; + case NORMAL: default: - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.0f; - case WIDE: - return 1.1f; - } - case NOTOSANS: - switch (lineSpacing) { - case TIGHT: - return 0.90f; - case NORMAL: - default: - return 0.95f; - case WIDE: - return 1.0f; - } - case OPENDYSLEXIC: - switch (lineSpacing) { - case TIGHT: - return 0.90f; - case NORMAL: - default: - return 0.95f; - case WIDE: - return 1.0f; - } + return 1.0f; + case WIDE: + return 1.1f; } } @@ -392,7 +384,7 @@ int CrossPointSettings::getReaderFontId() const { // resolveSdCardFontId() returns 0 if the named family isn't loaded // (e.g. SD card removed since selection) — fall through to built-in. if (sdFontFamilyName[0] != '\0') { - int id = resolveSdCardFontId(sdFontFamilyName); + int id = resolveSdCardFontId(sdFontFamilyName, fontSize); if (id != 0) return id; } return getBuiltinReaderFontId(fontFamily, fontSize); diff --git a/src/SdCardFontGlobals.h b/src/SdCardFontGlobals.h index 03d8517da3..f10e2cd273 100644 --- a/src/SdCardFontGlobals.h +++ b/src/SdCardFontGlobals.h @@ -11,11 +11,11 @@ extern SdCardFontSystem sdFontSystem; // Defined in main.cpp; call before entering the reader or after settings change. extern void ensureSdFontLoaded(); -// Resolve the SD card font ID for the given family name. -// Returns 0 if no SD font with that family name is currently loaded. +// Resolve the SD card font ID for the given family name and font size enum. +// Returns 0 if no SD font with that family name and size is currently loaded. // Free function (not stored as a callback in CrossPointSettings) so the linker // can resolve it directly without runtime indirection. -int resolveSdCardFontId(const char* familyName); +int resolveSdCardFontId(const char* familyName, uint8_t fontSizeEnum); // Trampolines used by the dynamic font-family SettingInfo. They walk // sdFontSystem's registry on each call to translate between diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index 35265b0989..da00eadee1 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -13,7 +13,9 @@ // Free-function resolver used by CrossPointSettings::getReaderFontId(). // Resolved by the linker — no callback indirection stored in settings. -int resolveSdCardFontId(const char* familyName) { return sdFontSystem.resolveFontId(familyName, 0); } +int resolveSdCardFontId(const char* familyName, uint8_t fontSizeEnum) { + return sdFontSystem.resolveFontId(familyName, fontSizeEnum); +} // --- Font-family dynamic SettingInfo trampolines --- // @@ -145,9 +147,17 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { } } -int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*fontSizeEnum*/) const { - // The manager loads exactly one size (closest to SETTINGS.fontSize), so the - // enum is implicit — always return the single loaded font ID for this family. - // ensureLoaded() must have been called with the current settings before this. +static uint8_t targetPtSizeFromEnum(uint8_t fontSizeEnum) { + if (fontSizeEnum >= sizeof(FONT_SIZE_TO_PT)) fontSizeEnum = 1; // default to MEDIUM + return FONT_SIZE_TO_PT[fontSizeEnum]; +} + +int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t fontSizeEnum) const { + // The manager loads exactly one size for the active SD family. Resolve only + // if the requested family matches the loaded family and the requested size + // matches the loaded size. otherwise return 0 so callers can fall back. + if (!familyName || familyName[0] == '\0') return 0; + if (manager_.currentFamilyName() != familyName) return 0; + if (manager_.currentPointSize() != targetPtSizeFromEnum(fontSizeEnum)) return 0; return manager_.getFontId(familyName); } diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 6d9ccbb931..2d04efa185 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -283,6 +283,7 @@ void ActivityManager::goToBrowser() { } void ActivityManager::goToReader(std::string path) { + RenderLock lock; ensureSdFontLoaded(); replaceActivity(std::make_unique(renderer, mappedInput, std::move(path))); } @@ -303,6 +304,7 @@ void ActivityManager::goToKOReaderSync() { void ActivityManager::replaceWithReader(std::string path, ReturnHint hint) { returnHint = std::move(hint); hasReturnHint = true; + RenderLock lock; ensureSdFontLoaded(); replaceActivity(std::make_unique(renderer, mappedInput, std::move(path))); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b30e4a4e17..dfab4fa7c3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -30,6 +30,7 @@ #include "QrDisplayActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" +#include "SdCardFontGlobals.h" #include "StarredPagesActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -1072,6 +1073,48 @@ uint8_t EpubReaderActivity::getEffectiveImageRendering() const { return SETTINGS.imageRendering; } +float EpubReaderActivity::getEffectiveReaderLineCompression() const { + const uint8_t fontSize = (bookFontSizeOverride >= 0) ? static_cast(bookFontSizeOverride) : SETTINGS.fontSize; + const int effectiveFontId = getEffectiveReaderFontId(); + const int bookerlyId = CrossPointSettings::getBuiltinReaderFontId(CrossPointSettings::BOOKERLY, fontSize); + const int notosansId = CrossPointSettings::getBuiltinReaderFontId(CrossPointSettings::NOTOSANS, fontSize); + const int opendyslexicId = CrossPointSettings::getBuiltinReaderFontId(CrossPointSettings::OPENDYSLEXIC, fontSize); + + if (effectiveFontId == notosansId) { + switch (SETTINGS.lineSpacing) { + case CrossPointSettings::TIGHT: + return 0.90f; + case CrossPointSettings::NORMAL: + default: + return 0.95f; + case CrossPointSettings::WIDE: + return 1.0f; + } + } + + if (effectiveFontId == opendyslexicId) { + switch (SETTINGS.lineSpacing) { + case CrossPointSettings::TIGHT: + return 0.90f; + case CrossPointSettings::NORMAL: + default: + return 0.95f; + case CrossPointSettings::WIDE: + return 1.0f; + } + } + + switch (SETTINGS.lineSpacing) { + case CrossPointSettings::TIGHT: + return 0.95f; + case CrossPointSettings::NORMAL: + default: + return 1.0f; + case CrossPointSettings::WIDE: + return 1.1f; + } +} + int EpubReaderActivity::getEffectiveReaderFontId() const { // Per-book font override: when set, force a specific BUILT-IN family even if // an SD card font is the global default. This makes the override predictable @@ -1086,8 +1129,8 @@ int EpubReaderActivity::getEffectiveReaderFontId() const { // SETTINGS.getReaderFontId() is the canonical answer. if (bookFontSizeOverride >= 0) { if (SETTINGS.sdFontFamilyName[0] != '\0') { - // SD font selected globally — size override doesn't change which family resolves. - return SETTINGS.getReaderFontId(); + const int id = resolveSdCardFontId(SETTINGS.sdFontFamilyName, fontSize); + if (id != 0) return id; } return CrossPointSettings::getBuiltinReaderFontId(SETTINGS.fontFamily, fontSize); } @@ -1206,7 +1249,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { section = std::make_unique
(epub, currentSpineIndex, renderer); const unsigned long sectionStart = millis(); - if (!section->loadSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), + if (!section->loadSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { LOG_DBG("ERS", "Cache not found, building..."); @@ -1227,7 +1270,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { // Pagination will rebuild only the cps it actually encounters, bounded // by MAX_PAGE_GLYPHS per style. renderer.clearSdCardFontAccumulation(); - if (!section->createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), + if (!section->createSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering, progressFn)) { @@ -1382,7 +1425,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW const uint8_t imageRendering = getEffectiveImageRendering(); Section nextSection(epub, nextSpineIndex, renderer); - if (nextSection.loadSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), + if (nextSection.loadSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { return; @@ -1391,7 +1434,7 @@ void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportW LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex); // Reset cumulative SD font metadata cache for the new section. renderer.clearSdCardFontAccumulation(); - if (!nextSection.createSectionFile(getEffectiveReaderFontId(), SETTINGS.getReaderLineCompression(), + if (!nextSection.createSectionFile(getEffectiveReaderFontId(), getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, embeddedStyle, imageRendering)) { LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); @@ -1744,12 +1787,12 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf auto section = std::make_unique
(epub, spineIndex, renderer); if (!section->loadSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), - SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, + getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { LOG_DBG("SLP", "EPUB: section cache not found for spine %d, rebuilding", spineIndex); if (!section->createSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), - SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, + getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { LOG_ERR("SLP", "EPUB: failed to rebuild section cache for spine %d", spineIndex); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 2ff6d56238..1fa1789bab 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -181,6 +181,7 @@ class EpubReaderActivity final : public Activity { bool getEffectiveEmbeddedStyle() const; uint8_t getEffectiveImageRendering() const; int getEffectiveReaderFontId() const; + float getEffectiveReaderLineCompression() const; bool stepPageState(bool isForwardTurn); void pageTurn(bool isForwardTurn); void runRenderBenchmark(); From b677fe54364f608c68f0e9edc88e6317e37bb526 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 16:09:59 +0200 Subject: [PATCH 05/13] One more --- lib/EpdFont/SdCardFont.cpp | 20 ++++++++++---------- lib/EpdFont/SdCardFont.h | 5 ++++- lib/GfxRenderer/GfxRenderer.cpp | 3 ++- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 2d29a13e80..8ad12863c8 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -574,7 +574,7 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) // --- Prewarm --- -int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly) { +int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOnly, bool loadKernLigatureData) { if (!loaded_) return -1; unsigned long startMs = millis(); @@ -624,10 +624,9 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn } // Add ligature output codepoints from all styles being prewarmed. - // Skip during metadata-only prewarm (layout measurement) to avoid loading - // kern/lig data for all styles upfront (~22KB per style). Kern/lig is - // loaded per-style in prewarmStyle() during the full render prewarm instead. - if (!metadataOnly) { + // Load ligature metadata when either doing a full prewarm or when + // metadata-only layout measurement needs applyLigatures()/getKerning(). + if (!metadataOnly || loadKernLigatureData) { for (uint8_t si = 0; si < MAX_STYLES; si++) { if (!(styleMask & (1 << si)) || !styles_[si].present) continue; auto& s = styles_[si]; @@ -669,7 +668,7 @@ int SdCardFont::prewarm(const char* utf8Text, uint8_t styleMask, bool metadataOn int totalMissed = 0; for (uint8_t si = 0; si < MAX_STYLES; si++) { if (!(styleMask & (1 << si)) || !styles_[si].present) continue; - totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly); + totalMissed += prewarmStyle(si, codepoints.get(), cpCount, metadataOnly, loadKernLigatureData); } stats_.prewarmTotalMs = millis() - startMs; @@ -702,7 +701,8 @@ bool SdCardFont::allCpsCovered(const PerStyle& s, const uint32_t* codepoints, ui return true; } -int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly) { +int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly, + bool loadKernLigatureData) { auto& s = styles_[styleIdx]; // ---- Fast-path coverage check ---- @@ -1104,10 +1104,10 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 // Full render prewarm: load the persistent kern classes + ligatures (one-time // per style, small — the big matrix is NOT loaded here) and then build the // per-page mini kern matrix restricted to class pairs reachable from this - // page's codepoints. Skip during metadata-only prewarm — layout only needs - // advanceX and the mini kern would be thrown away before rendering. + // page's codepoints. Also do this during layout-only metadata prewarm when + // kern/ligature metadata is explicitly requested. bool kernLigOk = false; - if (!metadataOnly) { + if (!metadataOnly || loadKernLigatureData) { if (loadStyleKernLigatureData(s)) { kernLigOk = buildMiniKernMatrix(s, codepoints, cpCount); } diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index e21f5c3439..108145b189 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -22,8 +22,11 @@ class SdCardFont { // styleMask: bitmask of styles to prewarm (bit 0=regular, 1=bold, 2=italic, 3=bolditalic). // Default 0x0F = all present styles. // When metadataOnly=true, only glyph metrics are loaded (no bitmap data). + // If loadKernLigatureData=true, kern/ligature metadata is also loaded so layout + // measurement calls that use applyLigatures()/getKerning() produce correct results. // Returns number of glyphs that couldn't be loaded (0 on full success). - int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false); + int prewarm(const char* utf8Text, uint8_t styleMask = 0x0F, bool metadataOnly = false, + bool loadKernLigatureData = false); // Free mini data for all styles, restore stub EpdFontData. void clearCache(); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 011f5a67a4..961a306391 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -43,7 +43,8 @@ void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text) const // Metadata-only: loads glyph metrics (advanceX) without bitmap data. // Saves ~50-100KB heap vs full prewarm — layout only needs advance widths. // Prewarm all present styles (0x0F) for layout measurement. - int missed = it->second->prewarm(utf8Text, 0x0F, /*metadataOnly=*/true); + int missed = it->second->prewarm(utf8Text, 0x0F, /*metadataOnly=*/true, + /*loadKernLigatureData=*/true); if (missed > 0) { LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed); } From e38b3f2e81d54800b369d264271ad455653ce537 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 16:16:18 +0200 Subject: [PATCH 06/13] And one more --- lib/GfxRenderer/FontCacheManager.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/FontCacheManager.cpp b/lib/GfxRenderer/FontCacheManager.cpp index d8e4685507..ded021da99 100644 --- a/lib/GfxRenderer/FontCacheManager.cpp +++ b/lib/GfxRenderer/FontCacheManager.cpp @@ -15,6 +15,10 @@ void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompress void FontCacheManager::clearCache() { if (fontDecompressor_) fontDecompressor_->clearCache(); for (auto& [id, font] : sdCardFonts_) { + if (!font) { + LOG_ERR("FCM", "clearCache: null SdCardFont pointer for fontId=%d", id); + continue; + } font->clearCache(); } } @@ -23,8 +27,15 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st // SD card font prewarm path: prewarm all requested styles in one call auto sdIt = sdCardFonts_.find(fontId); if (sdIt != sdCardFonts_.end()) { - int missed = sdIt->second->prewarm(utf8Text, styleMask); - if (missed > 0) { + SdCardFont* sdFont = sdIt->second; + if (!sdFont) { + LOG_ERR("FCM", "prewarmCache(SD): null SdCardFont pointer for fontId=%d", fontId); + return; + } + int missed = sdFont->prewarm(utf8Text, styleMask); + if (missed < 0) { + LOG_ERR("FCM", "prewarmCache(SD): prewarm failed for fontId=%d (styleMask=0x%02X)", fontId, styleMask); + } else if (missed > 0) { LOG_DBG("FCM", "prewarmCache(SD): %d glyph(s) not found (styleMask=0x%02X)", missed, styleMask); } return; @@ -48,14 +59,14 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st void FontCacheManager::logStats(const char* label) { if (fontDecompressor_) fontDecompressor_->logStats(label); for (auto& [id, font] : sdCardFonts_) { - font->logStats(label); + if (font) font->logStats(label); } } void FontCacheManager::resetStats() { if (fontDecompressor_) fontDecompressor_->resetStats(); for (auto& [id, font] : sdCardFonts_) { - font->resetStats(); + if (font) font->resetStats(); } } From f23370cb8a056f49c7d6b61fffbef78227de51cb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 16:18:53 +0200 Subject: [PATCH 07/13] Address hyphen review comment --- lib/Epub/Epub/ParsedText.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f081060de5..ba257c9ea9 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -156,7 +156,7 @@ void ParsedText::layoutAndExtractLines( // (advanceX only, no bitmaps) for all unique codepoints in this paragraph so // that calculateWordWidths() can measure text without on-demand SD I/O. if (renderer.isSdCardFont(fontId)) { - size_t totalSize = hyphenationEnabled ? 1 : 0; + size_t totalSize = 1; // reserve room for a possible hyphen fallback if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces for (const auto& w : words) totalSize += w.size(); std::string allText; @@ -165,7 +165,7 @@ void ParsedText::layoutAndExtractLines( if (i > 0) allText += ' '; allText += words[i]; } - if (hyphenationEnabled) allText += '-'; + allText += '-'; renderer.ensureSdCardFontReady(fontId, allText.c_str()); } From 407816982c08010ad1e7683db3d9b6d5700ea8c5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 17:46:37 +0200 Subject: [PATCH 08/13] Fix signature Co-authored-by: Copilot --- lib/EpdFont/SdCardFont.cpp | 2 +- lib/EpdFont/SdCardFont.h | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 8ad12863c8..7da85eb8a0 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -800,7 +800,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 freeStyleMiniData(s); // Recurse with rebuild semantics by clearing tryMerge state and trying // again — implemented as inline reset below. - return prewarmStyle(styleIdx, codepoints, cpCount, metadataOnly); + return prewarmStyle(styleIdx, codepoints, cpCount, metadataOnly, loadKernLigatureData); } mappings[validCount].codepoint = cp; mappings[validCount].globalIndex = idx; diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 108145b189..07fab5a305 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -204,7 +204,8 @@ class SdCardFont { void applyKernLigaturePointers(PerStyle& s, EpdFontData& data) const; void applyGlyphMissCallback(uint8_t styleIdx); int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; - int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly); + int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly, + bool loadKernLigatureData); // Global helpers void freeAll(); From 519a47e040dd1f06b04a9b98e49b81cf231e0e67 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 18:06:56 +0200 Subject: [PATCH 09/13] Some minor fixes Co-authored-by: Copilot --- lib/EpdFont/SdCardFont.cpp | 1 + lib/EpdFont/SdCardFont.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 40 +++++++++++++++++--- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 7da85eb8a0..7416670680 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -8,6 +8,7 @@ #include #include #include +#include static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout"); static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout"); diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 07fab5a305..9564622d26 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "EpdFont.h" #include "EpdFontData.h" diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index dfab4fa7c3..c678bc14ab 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1785,14 +1785,44 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf } }; + const int effectiveFontId = getEffectiveFontId(effectiveFontFamily, effectiveFontSize); + const auto getEffectiveLineCompression = [&](int fontId) { + const int notosansId = CrossPointSettings::getBuiltinReaderFontId(CrossPointSettings::NOTOSANS, effectiveFontSize); + const int opendyslexicId = + CrossPointSettings::getBuiltinReaderFontId(CrossPointSettings::OPENDYSLEXIC, effectiveFontSize); + + if (fontId == notosansId || fontId == opendyslexicId) { + switch (SETTINGS.lineSpacing) { + case CrossPointSettings::TIGHT: + return 0.90f; + case CrossPointSettings::NORMAL: + default: + return 0.95f; + case CrossPointSettings::WIDE: + return 1.0f; + } + } + + switch (SETTINGS.lineSpacing) { + case CrossPointSettings::TIGHT: + return 0.95f; + case CrossPointSettings::NORMAL: + default: + return 1.0f; + case CrossPointSettings::WIDE: + return 1.1f; + } + }; + + const float effectiveLineCompression = getEffectiveLineCompression(effectiveFontId); auto section = std::make_unique
(epub, spineIndex, renderer); - if (!section->loadSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), - getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, - SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, - SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { + if (!section->loadSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), effectiveLineCompression, + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering)) { LOG_DBG("SLP", "EPUB: section cache not found for spine %d, rebuilding", spineIndex); if (!section->createSectionFile(getEffectiveFontId(effectiveFontFamily, effectiveFontSize), - getEffectiveReaderLineCompression(), SETTINGS.extraParagraphSpacing, + effectiveLineCompression, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering)) { LOG_ERR("SLP", "EPUB: failed to rebuild section cache for spine %d", spineIndex); From 9259718a07f9cdc06b0b3171f68f155d0170e968 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 18:14:58 +0200 Subject: [PATCH 10/13] Use bitmap.pitch instead of assuming tightly packed rows --- lib/EpdFont/scripts/fontconvert_sdcard.py | 24 ++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index 8401515057..e2c8257ab6 100644 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -503,15 +503,21 @@ def load_glyph(code_point): # Build 4-bit greyscale bitmap (same logic as fontconvert.py) pixels4g = [] px = 0 - for i, v in enumerate(bitmap.buffer): - x = i % bitmap.width - if x % 2 == 0: - px = (v >> 4) + abs_pitch = abs(bitmap.pitch) + for y in range(bitmap.rows): + if bitmap.pitch >= 0: + row_offset = y * abs_pitch else: - px = px | (v & 0xF0) - pixels4g.append(px) - px = 0 - if x == bitmap.width - 1 and bitmap.width % 2 > 0: + row_offset = (bitmap.rows - 1 - y) * abs_pitch + for x in range(bitmap.width): + v = bitmap.buffer[row_offset + x] + if x % 2 == 0: + px = (v >> 4) + else: + px = px | (v & 0xF0) + pixels4g.append(px) + px = 0 + if bitmap.width % 2 > 0: pixels4g.append(px) px = 0 @@ -536,7 +542,7 @@ def load_glyph(code_point): pixels2b.append(px) px = 0 if (bitmap.width * bitmap.rows) % 4 != 0: - px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2 + px = px << ((4 - (bitmap.width * bitmap.rows) % 4) * 2) pixels2b.append(px) packed = bytes(pixels2b) From 6053d050ccf5a7d23a752561a82461719a5c31ed Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 18:40:37 +0200 Subject: [PATCH 11/13] Preserve continuation boundaries when building SD prewarm text --- lib/Epub/Epub/ParsedText.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index ba257c9ea9..c13a8e99e1 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -156,13 +156,15 @@ void ParsedText::layoutAndExtractLines( // (advanceX only, no bitmaps) for all unique codepoints in this paragraph so // that calculateWordWidths() can measure text without on-demand SD I/O. if (renderer.isSdCardFont(fontId)) { - size_t totalSize = 1; // reserve room for a possible hyphen fallback - if (!words.empty()) totalSize += words.size() - 1; // inter-word spaces - for (const auto& w : words) totalSize += w.size(); + size_t totalSize = 1; // reserve room for a possible hyphen fallback + for (size_t i = 0; i < words.size(); i++) { + if (i > 0 && !wordContinues[i]) totalSize += 1; + totalSize += words[i].size(); + } std::string allText; allText.reserve(totalSize); for (size_t i = 0; i < words.size(); i++) { - if (i > 0) allText += ' '; + if (i > 0 && !wordContinues[i]) allText += ' '; allText += words[i]; } allText += '-'; From dbfff28556667956f266d292be8c632b49c91a51 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 18:41:48 +0200 Subject: [PATCH 12/13] Make SdCardFont explicitly non-copyable and non-movable Co-authored-by: Copilot --- lib/EpdFont/SdCardFont.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 9564622d26..ff5f9db15c 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -12,6 +12,10 @@ class SdCardFont { static constexpr uint8_t MAX_STYLES = 4; SdCardFont() = default; + SdCardFont(const SdCardFont&) = delete; + SdCardFont& operator=(const SdCardFont&) = delete; + SdCardFont(SdCardFont&&) = delete; + SdCardFont& operator=(SdCardFont&&) = delete; ~SdCardFont(); // Load .cpfont file: reads header + intervals into RAM, records file layout offsets. From 5e892657fae893501726b8d4d148ed3de2e6bd67 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 28 Apr 2026 19:03:38 +0200 Subject: [PATCH 13/13] Add destructor to unregister fonts from GfxRenderer --- lib/EpdFont/SdCardFontManager.cpp | 15 +++++++++++++-- lib/EpdFont/SdCardFontManager.h | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/EpdFont/SdCardFontManager.cpp b/lib/EpdFont/SdCardFontManager.cpp index 5b8a2da3d3..619e749af7 100644 --- a/lib/EpdFont/SdCardFontManager.cpp +++ b/lib/EpdFont/SdCardFontManager.cpp @@ -9,8 +9,13 @@ #include SdCardFontManager::~SdCardFontManager() { - for (auto& lf : loaded_) { - delete lf.font; + if (renderer_) { + unloadAll(*renderer_); + } else { + for (auto& lf : loaded_) { + delete lf.font; + } + loaded_.clear(); } } @@ -31,6 +36,9 @@ int SdCardFontManager::computeFontId(uint32_t contentHash, const char* familyNam } bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t targetPtSize) { + if (!renderer_) { + renderer_ = &renderer; + } // Unload any previously loaded family first if (!loadedFamilyName_.empty()) { unloadAll(renderer); @@ -80,6 +88,9 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender } void SdCardFontManager::unloadAll(GfxRenderer& renderer) { + if (!renderer_) { + renderer_ = &renderer; + } renderer.clearSdCardFonts(); for (auto& lf : loaded_) { renderer.removeFont(lf.fontId); diff --git a/lib/EpdFont/SdCardFontManager.h b/lib/EpdFont/SdCardFontManager.h index c60dd63fec..659653f864 100644 --- a/lib/EpdFont/SdCardFontManager.h +++ b/lib/EpdFont/SdCardFontManager.h @@ -44,6 +44,7 @@ class SdCardFontManager { }; static int computeFontId(uint32_t contentHash, const char* familyName, uint8_t pointSize); + GfxRenderer* renderer_ = nullptr; std::string loadedFamilyName_; uint8_t loadedPointSize_ = 0; std::vector loaded_;