From 68ce6db2914648fd2b92ede4e762c1f2c291808b Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 18:46:23 +0900 Subject: [PATCH 1/6] feat: Add custom font selection from SD card Allow users to select custom fonts (.epdfont files) from the /.crosspoint/fonts/ directory on the SD card for EPUB/TXT reading. Features: - New FontSelectionActivity for browsing and selecting fonts - SdFont and SdFontFamily classes for loading fonts from SD card - Dynamic font reloading without device reboot - Reader cache invalidation when font changes - Hash-based font ID generation for proper cache management The custom fonts use the .epdfont binary format which supports: - 2-bit antialiasing for smooth text rendering - Efficient on-demand glyph loading with LRU cache - Memory-optimized design for ESP32-C3 constraints --- lib/EpdFont/EpdFontFamily.h | 10 + lib/EpdFont/SdFont.cpp | 566 ++++++++++++++++++ lib/EpdFont/SdFont.h | 184 ++++++ lib/EpdFont/SdFontFamily.cpp | 296 +++++++++ lib/EpdFont/SdFontFamily.h | 115 ++++ lib/EpdFont/SdFontFormat.h | 79 +++ lib/GfxRenderer/GfxRenderer.cpp | 198 ++++-- lib/GfxRenderer/GfxRenderer.h | 43 +- src/CrossPointSettings.cpp | 41 +- src/CrossPointSettings.h | 7 + src/FontManager.h | 11 + .../settings/FontSelectionActivity.cpp | 335 +++++++++++ .../settings/FontSelectionActivity.h | 43 ++ src/activities/settings/SettingsActivity.cpp | 14 +- src/fontIds.h | 3 + src/main.cpp | 105 +++- 16 files changed, 1968 insertions(+), 82 deletions(-) create mode 100644 lib/EpdFont/SdFont.cpp create mode 100644 lib/EpdFont/SdFont.h create mode 100644 lib/EpdFont/SdFontFamily.cpp create mode 100644 lib/EpdFont/SdFontFamily.h create mode 100644 lib/EpdFont/SdFontFormat.h create mode 100644 src/FontManager.h create mode 100644 src/activities/settings/FontSelectionActivity.cpp create mode 100644 src/activities/settings/FontSelectionActivity.h diff --git a/lib/EpdFont/EpdFontFamily.h b/lib/EpdFont/EpdFontFamily.h index 92043d1f..69ceb4e6 100644 --- a/lib/EpdFont/EpdFontFamily.h +++ b/lib/EpdFont/EpdFontFamily.h @@ -14,6 +14,9 @@ class EpdFontFamily { const EpdFontData* getData(Style style = REGULAR) const; const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const; + // Check if bold variant is available (for synthetic bold decision) + bool hasBold() const { return bold != nullptr; } + private: const EpdFont* regular; const EpdFont* bold; @@ -22,3 +25,10 @@ class EpdFontFamily { const EpdFont* getFont(Style style) const; }; + +// Global typedef for use outside class scope (needed by SdFontFamily and GfxRenderer) +using EpdFontStyle = EpdFontFamily::Style; +constexpr EpdFontStyle REGULAR = EpdFontFamily::REGULAR; +constexpr EpdFontStyle BOLD = EpdFontFamily::BOLD; +constexpr EpdFontStyle ITALIC = EpdFontFamily::ITALIC; +constexpr EpdFontStyle BOLD_ITALIC = EpdFontFamily::BOLD_ITALIC; diff --git a/lib/EpdFont/SdFont.cpp b/lib/EpdFont/SdFont.cpp new file mode 100644 index 00000000..6dfcd73b --- /dev/null +++ b/lib/EpdFont/SdFont.cpp @@ -0,0 +1,566 @@ +#include "SdFont.h" + +#include +#include +#include +#include + +#include +#include +#include + +// ============================================================================ +// GlyphBitmapCache Implementation +// ============================================================================ + +GlyphBitmapCache::GlyphBitmapCache(size_t maxSize) : maxCacheSize(maxSize), currentSize(0) {} + +GlyphBitmapCache::~GlyphBitmapCache() { clear(); } + +void GlyphBitmapCache::evictOldest() { + while (currentSize > maxCacheSize && !cacheList.empty()) { + auto& oldest = cacheList.back(); + currentSize -= oldest.size; + cacheMap.erase(oldest.codepoint); + free(oldest.bitmap); + cacheList.pop_back(); + } +} + +const uint8_t* GlyphBitmapCache::get(uint32_t codepoint) { + auto it = cacheMap.find(codepoint); + if (it == cacheMap.end()) { + return nullptr; + } + + // Move to front (most recently used) + if (it->second != cacheList.begin()) { + cacheList.splice(cacheList.begin(), cacheList, it->second); + } + + return it->second->bitmap; +} + +const uint8_t* GlyphBitmapCache::put(uint32_t codepoint, const uint8_t* data, uint32_t size) { + // Check if already cached + auto it = cacheMap.find(codepoint); + if (it != cacheMap.end()) { + // Move to front + if (it->second != cacheList.begin()) { + cacheList.splice(cacheList.begin(), cacheList, it->second); + } + return it->second->bitmap; + } + + // Allocate and copy bitmap data + uint8_t* bitmapCopy = static_cast(malloc(size)); + if (!bitmapCopy) { + Serial.printf("[%lu] [SdFont] Failed to allocate %u bytes for glyph cache\n", millis(), size); + return nullptr; + } + memcpy(bitmapCopy, data, size); + + // Add to cache + CacheEntry entry = {codepoint, bitmapCopy, size}; + cacheList.push_front(entry); + cacheMap[codepoint] = cacheList.begin(); + currentSize += size; + + // Evict if over limit + evictOldest(); + + return bitmapCopy; +} + +void GlyphBitmapCache::clear() { + for (auto& entry : cacheList) { + free(entry.bitmap); + } + cacheList.clear(); + cacheMap.clear(); + currentSize = 0; +} + +// ============================================================================ +// GlyphMetadataCache Implementation (simple fixed-size circular buffer) +// ============================================================================ + +const EpdGlyph* GlyphMetadataCache::get(uint32_t codepoint) { + // Linear search through cache (simple but effective for small cache) + for (size_t i = 0; i < MAX_ENTRIES; i++) { + if (entries[i].valid && entries[i].codepoint == codepoint) { + return &entries[i].glyph; + } + } + return nullptr; +} + +const EpdGlyph* GlyphMetadataCache::put(uint32_t codepoint, const EpdGlyph& glyph) { + // Check if already cached + for (size_t i = 0; i < MAX_ENTRIES; i++) { + if (entries[i].valid && entries[i].codepoint == codepoint) { + return &entries[i].glyph; + } + } + + // Add to next slot (circular overwrite) + entries[nextSlot].codepoint = codepoint; + entries[nextSlot].glyph = glyph; + entries[nextSlot].valid = true; + + const EpdGlyph* result = &entries[nextSlot].glyph; + nextSlot = (nextSlot + 1) % MAX_ENTRIES; + return result; +} + +void GlyphMetadataCache::clear() { + for (size_t i = 0; i < MAX_ENTRIES; i++) { + entries[i].valid = false; + } + nextSlot = 0; +} + +// ============================================================================ +// SdFontData Implementation +// ============================================================================ + +// Static members +GlyphBitmapCache* SdFontData::sharedCache = nullptr; +int SdFontData::cacheRefCount = 0; + +SdFontData::SdFontData(const char* path) : filePath(path), loaded(false), intervals(nullptr) { + memset(&header, 0, sizeof(header)); + + // Initialize shared cache on first SdFontData creation + // Use larger cache (64KB) to improve performance with Korean fonts + if (sharedCache == nullptr) { + sharedCache = new GlyphBitmapCache(32768); // 32KB cache (conserve memory for XTC) + } + cacheRefCount++; +} + +SdFontData::~SdFontData() { + if (fontFile) { + fontFile.close(); + } + + delete[] intervals; + + // Cleanup shared cache when last SdFontData is destroyed + cacheRefCount--; + if (cacheRefCount == 0 && sharedCache != nullptr) { + delete sharedCache; + sharedCache = nullptr; + } +} + +SdFontData::SdFontData(SdFontData&& other) noexcept + : filePath(std::move(other.filePath)), loaded(other.loaded), header(other.header), intervals(other.intervals) { + other.intervals = nullptr; + other.loaded = false; + cacheRefCount++; // New instance references the cache +} + +SdFontData& SdFontData::operator=(SdFontData&& other) noexcept { + if (this != &other) { + // Clean up current resources + if (fontFile) { + fontFile.close(); + } + delete[] intervals; + + // Move from other + filePath = std::move(other.filePath); + loaded = other.loaded; + header = other.header; + intervals = other.intervals; + + other.intervals = nullptr; + other.loaded = false; + } + return *this; +} + +// Maximum reasonable values for validation +// CJK fonts (Korean + Chinese + Japanese) can have 120K+ glyphs +// Glyphs are loaded on-demand from SD, so high count doesn't affect memory +static constexpr uint32_t MAX_INTERVAL_COUNT = 10000; +static constexpr uint32_t MAX_GLYPH_COUNT = 150000; +static constexpr size_t MIN_FREE_HEAP_AFTER_LOAD = 16384; // 16KB minimum heap after loading + +bool SdFontData::load() { + if (loaded) { + return true; + } + + // Check available heap before attempting to load + size_t freeHeap = ESP.getFreeHeap(); + if (freeHeap < MIN_FREE_HEAP_AFTER_LOAD) { + Serial.printf("[%lu] [SdFont] Insufficient heap: %u bytes (need %u)\n", millis(), freeHeap, + MIN_FREE_HEAP_AFTER_LOAD); + return false; + } + + // Open font file + if (!SdMan.openFileForRead("SdFont", filePath.c_str(), fontFile)) { + Serial.printf("[%lu] [SdFont] Failed to open font file: %s\n", millis(), filePath.c_str()); + return false; + } + + // Read and validate header + if (fontFile.read(&header, sizeof(EpdFontHeader)) != sizeof(EpdFontHeader)) { + Serial.printf("[%lu] [SdFont] Failed to read header from: %s\n", millis(), filePath.c_str()); + fontFile.close(); + return false; + } + + // Validate magic number + if (header.magic != EPDFONT_MAGIC) { + Serial.printf("[%lu] [SdFont] Invalid magic: 0x%08X (expected 0x%08X)\n", millis(), header.magic, EPDFONT_MAGIC); + fontFile.close(); + return false; + } + + // Validate version + if (header.version != EPDFONT_VERSION) { + Serial.printf("[%lu] [SdFont] Bad version: %u (expected %u)\n", millis(), header.version, EPDFONT_VERSION); + fontFile.close(); + return false; + } + + // Validate header values to prevent memory issues + if (header.intervalCount > MAX_INTERVAL_COUNT) { + Serial.printf("[%lu] [SdFont] Too many intervals: %u (max %u)\n", millis(), header.intervalCount, + MAX_INTERVAL_COUNT); + fontFile.close(); + return false; + } + + if (header.glyphCount > MAX_GLYPH_COUNT) { + Serial.printf("[%lu] [SdFont] Too many glyphs: %u (max %u)\n", millis(), header.glyphCount, MAX_GLYPH_COUNT); + fontFile.close(); + return false; + } + + // Calculate required memory - only intervals are loaded into RAM + // Glyphs are loaded on-demand from SD card to save memory + size_t intervalsMemory = header.intervalCount * sizeof(EpdFontInterval); + + if (intervalsMemory > freeHeap - MIN_FREE_HEAP_AFTER_LOAD) { + Serial.printf("[%lu] [SdFont] Not enough memory for intervals: need %u, have %u\n", millis(), intervalsMemory, + freeHeap); + fontFile.close(); + return false; + } + + Serial.printf("[%lu] [SdFont] Loading %s: %u intervals, %u glyphs (on-demand)\n", millis(), filePath.c_str(), + header.intervalCount, header.glyphCount); + + // Allocate intervals array + intervals = new (std::nothrow) EpdFontInterval[header.intervalCount]; + if (intervals == nullptr) { + Serial.printf("[%lu] [SdFont] Failed to allocate intervals (%u bytes)\n", millis(), intervalsMemory); + fontFile.close(); + return false; + } + + // Read intervals - data should be contiguous after header, but verify offset + // Expected offset for intervals is 32 (right after header) + if (header.intervalsOffset != sizeof(EpdFontHeader)) { + // Need to seek - file layout is non-standard + if (!fontFile.seekSet(header.intervalsOffset)) { + Serial.printf("[%lu] [SdFont] Failed to seek to intervals at %u\n", millis(), header.intervalsOffset); + fontFile.close(); + delete[] intervals; + intervals = nullptr; + return false; + } + } + // Otherwise, we're already positioned right after header - read directly + + if (fontFile.read(intervals, intervalsMemory) != static_cast(intervalsMemory)) { + Serial.printf("[%lu] [SdFont] Failed to read intervals\n", millis()); + fontFile.close(); + delete[] intervals; + intervals = nullptr; + return false; + } + + // Close the file after loading intervals - we'll reopen when reading glyphs/bitmaps + fontFile.close(); + + loaded = true; + Serial.printf("[%lu] [SdFont] Loaded: %s (advanceY=%u, intervals=%uKB)\n", millis(), filePath.c_str(), + header.advanceY, intervalsMemory / 1024); + + return true; +} + +bool SdFontData::ensureFileOpen() const { + if (fontFile && fontFile.isOpen()) { + return true; + } + return SdMan.openFileForRead("SdFont", filePath.c_str(), fontFile); +} + +bool SdFontData::loadGlyphFromSD(int glyphIndex, EpdGlyph* outGlyph) const { + if (!loaded || glyphIndex < 0 || glyphIndex >= static_cast(header.glyphCount)) { + return false; + } + + // Keep file open for better performance + if (!ensureFileOpen()) { + return false; + } + + // Calculate position in file + uint32_t glyphFileOffset = header.glyphsOffset + (glyphIndex * sizeof(EpdFontGlyph)); + + if (!fontFile.seekSet(glyphFileOffset)) { + return false; + } + + // Read the glyph from file format + EpdFontGlyph fileGlyph; + if (fontFile.read(&fileGlyph, sizeof(EpdFontGlyph)) != sizeof(EpdFontGlyph)) { + return false; + } + + // Convert from file format to runtime format + outGlyph->width = fileGlyph.width; + outGlyph->height = fileGlyph.height; + outGlyph->advanceX = fileGlyph.advanceX; + outGlyph->left = fileGlyph.left; + outGlyph->top = fileGlyph.top; + outGlyph->dataLength = static_cast(fileGlyph.dataLength); + outGlyph->dataOffset = fileGlyph.dataOffset; + + return true; +} + +int SdFontData::findGlyphIndex(uint32_t codepoint) const { + if (!loaded || intervals == nullptr) { + return -1; + } + + // Binary search for the interval containing this codepoint + int left = 0; + int right = static_cast(header.intervalCount) - 1; + + while (left <= right) { + int mid = left + (right - left) / 2; + const EpdFontInterval* interval = &intervals[mid]; + + if (codepoint < interval->first) { + right = mid - 1; + } else if (codepoint > interval->last) { + left = mid + 1; + } else { + // Found: codepoint is within this interval + return static_cast(interval->offset + (codepoint - interval->first)); + } + } + + return -1; // Not found +} + +const EpdGlyph* SdFontData::getGlyph(uint32_t codepoint) const { + if (!loaded) { + return nullptr; + } + + // Check cache first + const EpdGlyph* cached = glyphCache.get(codepoint); + if (cached != nullptr) { + return cached; + } + + // Find glyph index using binary search on intervals + int index = findGlyphIndex(codepoint); + if (index < 0 || index >= static_cast(header.glyphCount)) { + return nullptr; + } + + // Load glyph from SD card + EpdGlyph glyph; + if (!loadGlyphFromSD(index, &glyph)) { + return nullptr; + } + + // Store in cache and return pointer to cached copy + return glyphCache.put(codepoint, glyph); +} + +const uint8_t* SdFontData::getGlyphBitmap(uint32_t codepoint) const { + if (!loaded || sharedCache == nullptr) { + return nullptr; + } + + // Check cache first + const uint8_t* cached = sharedCache->get(codepoint); + if (cached != nullptr) { + return cached; + } + + // Find glyph index + int glyphIndex = findGlyphIndex(codepoint); + if (glyphIndex < 0 || glyphIndex >= static_cast(header.glyphCount)) { + return nullptr; + } + + // Ensure file is open (keeps file handle open for performance) + if (!ensureFileOpen()) { + return nullptr; + } + + // Read glyph metadata first (we need dataLength and dataOffset) + uint32_t glyphFileOffset = header.glyphsOffset + (glyphIndex * sizeof(EpdFontGlyph)); + if (!fontFile.seekSet(glyphFileOffset)) { + return nullptr; + } + + EpdFontGlyph fileGlyph; + if (fontFile.read(&fileGlyph, sizeof(EpdFontGlyph)) != sizeof(EpdFontGlyph)) { + return nullptr; + } + + if (fileGlyph.dataLength == 0) { + return nullptr; + } + + // Seek to bitmap data + if (!fontFile.seekSet(header.bitmapOffset + fileGlyph.dataOffset)) { + return nullptr; + } + + // Allocate temporary buffer for reading + uint8_t* tempBuffer = static_cast(malloc(fileGlyph.dataLength)); + if (!tempBuffer) { + return nullptr; + } + + if (fontFile.read(tempBuffer, fileGlyph.dataLength) != static_cast(fileGlyph.dataLength)) { + free(tempBuffer); + return nullptr; + } + + // File stays open for next glyph read (performance optimization) + + // Store in cache + const uint8_t* result = sharedCache->put(codepoint, tempBuffer, fileGlyph.dataLength); + free(tempBuffer); + + return result; +} + +void SdFontData::setCacheSize(size_t maxBytes) { + if (sharedCache != nullptr) { + delete sharedCache; + } + sharedCache = new GlyphBitmapCache(maxBytes); +} + +void SdFontData::clearCache() { + if (sharedCache != nullptr) { + sharedCache->clear(); + } +} + +size_t SdFontData::getCacheUsedSize() { + if (sharedCache != nullptr) { + return sharedCache->getUsedSize(); + } + return 0; +} + +// ============================================================================ +// SdFont Implementation +// ============================================================================ + +SdFont::SdFont(SdFontData* fontData, bool takeOwnership) : data(fontData), ownsData(takeOwnership) {} + +SdFont::SdFont(const char* filePath) : data(new SdFontData(filePath)), ownsData(true) {} + +SdFont::~SdFont() { + if (ownsData) { + delete data; + } +} + +SdFont::SdFont(SdFont&& other) noexcept : data(other.data), ownsData(other.ownsData) { + other.data = nullptr; + other.ownsData = false; +} + +SdFont& SdFont::operator=(SdFont&& other) noexcept { + if (this != &other) { + if (ownsData) { + delete data; + } + data = other.data; + ownsData = other.ownsData; + other.data = nullptr; + other.ownsData = false; + } + return *this; +} + +bool SdFont::load() { + if (data == nullptr) { + return false; + } + return data->load(); +} + +void SdFont::getTextDimensions(const char* string, int* w, int* h) const { + *w = 0; + *h = 0; + + if (data == nullptr || !data->isLoaded() || string == nullptr || *string == '\0') { + return; + } + + int minX = 0, minY = 0, maxX = 0, maxY = 0; + int cursorX = 0; + const int cursorY = 0; + + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&string)))) { + const EpdGlyph* glyph = data->getGlyph(cp); + if (!glyph) { + glyph = data->getGlyph('?'); + } + if (!glyph) { + continue; + } + + minX = std::min(minX, cursorX + glyph->left); + maxX = std::max(maxX, cursorX + glyph->left + glyph->width); + minY = std::min(minY, cursorY + glyph->top - glyph->height); + maxY = std::max(maxY, cursorY + glyph->top); + cursorX += glyph->advanceX; + } + + *w = maxX - minX; + *h = maxY - minY; +} + +bool SdFont::hasPrintableChars(const char* string) const { + int w = 0, h = 0; + getTextDimensions(string, &w, &h); + return w > 0 || h > 0; +} + +const EpdGlyph* SdFont::getGlyph(uint32_t cp) const { + if (data == nullptr) { + return nullptr; + } + return data->getGlyph(cp); +} + +const uint8_t* SdFont::getGlyphBitmap(uint32_t cp) const { + if (data == nullptr) { + return nullptr; + } + return data->getGlyphBitmap(cp); +} diff --git a/lib/EpdFont/SdFont.h b/lib/EpdFont/SdFont.h new file mode 100644 index 00000000..2594111f --- /dev/null +++ b/lib/EpdFont/SdFont.h @@ -0,0 +1,184 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "EpdFontData.h" +#include "SdFontFormat.h" + +/** + * LRU Cache for glyph bitmap data loaded from SD card. + * Automatically evicts least recently used entries when memory limit is reached. + */ +class GlyphBitmapCache { + public: + struct CacheEntry { + uint32_t codepoint; + uint8_t* bitmap; + uint32_t size; + }; + + private: + size_t maxCacheSize; + size_t currentSize; + std::list cacheList; // Most recent at front + std::unordered_map::iterator> cacheMap; + + void evictOldest(); + + public: + explicit GlyphBitmapCache(size_t maxSize = 32768); // Default 32KB cache + ~GlyphBitmapCache(); + + // Returns cached bitmap or nullptr if not cached + const uint8_t* get(uint32_t codepoint); + + // Stores bitmap in cache, returns pointer to cached data + const uint8_t* put(uint32_t codepoint, const uint8_t* data, uint32_t size); + + void clear(); + size_t getUsedSize() const { return currentSize; } + size_t getMaxSize() const { return maxCacheSize; } +}; + +/** + * SD Card font data structure. + * Mimics EpdFontData interface but loads data on-demand from SD card. + */ +/** + * Simple fixed-size cache for glyph metadata (EpdGlyph) loaded on-demand. + * Uses a simple circular buffer to avoid STL container overhead on ESP32. + */ +class GlyphMetadataCache { + public: + static constexpr size_t MAX_ENTRIES = 128; // Balanced for Korean text while conserving memory + + struct CacheEntry { + uint32_t codepoint; + EpdGlyph glyph; + bool valid; + }; + + private: + CacheEntry entries[MAX_ENTRIES]; + size_t nextSlot; + + public: + GlyphMetadataCache() : nextSlot(0) { + for (size_t i = 0; i < MAX_ENTRIES; i++) { + entries[i].valid = false; + } + } + + const EpdGlyph* get(uint32_t codepoint); + const EpdGlyph* put(uint32_t codepoint, const EpdGlyph& glyph); + void clear(); +}; + +class SdFontData { + private: + std::string filePath; + bool loaded; + + // Font metadata (loaded once, kept in RAM) + EpdFontHeader header; + EpdFontInterval* intervals; // Dynamically allocated (~40KB for Korean) + // Note: glyphs are NOT preloaded - loaded on-demand to save memory + + // Glyph metadata cache (per-font, small LRU cache) + mutable GlyphMetadataCache glyphCache; + + // Bitmap cache (shared across all SdFontData instances) + static GlyphBitmapCache* sharedCache; + static int cacheRefCount; + + // File handle for reading (opened on demand) + mutable FsFile fontFile; + + // Binary search for glyph index + int findGlyphIndex(uint32_t codepoint) const; + + // Load a single glyph from SD card by index + bool loadGlyphFromSD(int glyphIndex, EpdGlyph* outGlyph) const; + + // Ensure font file is open (keeps handle open for performance) + bool ensureFileOpen() const; + + public: + explicit SdFontData(const char* path); + ~SdFontData(); + + // Disable copy to prevent resource issues + SdFontData(const SdFontData&) = delete; + SdFontData& operator=(const SdFontData&) = delete; + + // Move constructor and assignment + SdFontData(SdFontData&& other) noexcept; + SdFontData& operator=(SdFontData&& other) noexcept; + + // Load font header and metadata from SD card + bool load(); + bool isLoaded() const { return loaded; } + + // EpdFontData-compatible getters + uint8_t getAdvanceY() const { return header.advanceY; } + int8_t getAscender() const { return header.ascender; } + int8_t getDescender() const { return header.descender; } + bool is2Bit() const { return header.is2Bit != 0; } + uint32_t getIntervalCount() const { return header.intervalCount; } + uint32_t getGlyphCount() const { return header.glyphCount; } + + // Get glyph by codepoint (loads bitmap on demand) + const EpdGlyph* getGlyph(uint32_t codepoint) const; + + // Get bitmap for a glyph (loads from SD if not cached) + const uint8_t* getGlyphBitmap(uint32_t codepoint) const; + + // Static cache management + static void setCacheSize(size_t maxBytes); + static void clearCache(); + static size_t getCacheUsedSize(); +}; + +/** + * SD Card font class - similar interface to EpdFont but loads from SD card. + */ +class SdFont { + private: + SdFontData* data; + bool ownsData; + + public: + explicit SdFont(SdFontData* fontData, bool takeOwnership = false); + explicit SdFont(const char* filePath); + ~SdFont(); + + // Disable copy + SdFont(const SdFont&) = delete; + SdFont& operator=(const SdFont&) = delete; + + // Move semantics + SdFont(SdFont&& other) noexcept; + SdFont& operator=(SdFont&& other) noexcept; + + bool load(); + bool isLoaded() const { return data && data->isLoaded(); } + + // EpdFont-compatible interface + void getTextDimensions(const char* string, int* w, int* h) const; + bool hasPrintableChars(const char* string) const; + const EpdGlyph* getGlyph(uint32_t cp) const; + const uint8_t* getGlyphBitmap(uint32_t cp) const; + + // Metadata accessors + uint8_t getAdvanceY() const { return data ? data->getAdvanceY() : 0; } + int8_t getAscender() const { return data ? data->getAscender() : 0; } + int8_t getDescender() const { return data ? data->getDescender() : 0; } + bool is2Bit() const { return data ? data->is2Bit() : false; } + + SdFontData* getData() const { return data; } +}; diff --git a/lib/EpdFont/SdFontFamily.cpp b/lib/EpdFont/SdFontFamily.cpp new file mode 100644 index 00000000..afc4652e --- /dev/null +++ b/lib/EpdFont/SdFontFamily.cpp @@ -0,0 +1,296 @@ +#include "SdFontFamily.h" + +#include + +// ============================================================================ +// SdFontFamily Implementation +// ============================================================================ + +SdFontFamily::SdFontFamily(const char* regularPath, const char* boldPath, const char* italicPath, + const char* boldItalicPath) + : regular(nullptr), bold(nullptr), italic(nullptr), boldItalic(nullptr), ownsPointers(true) { + if (regularPath) { + regular = new SdFont(regularPath); + } + if (boldPath) { + bold = new SdFont(boldPath); + } + if (italicPath) { + italic = new SdFont(italicPath); + } + if (boldItalicPath) { + boldItalic = new SdFont(boldItalicPath); + } +} + +SdFontFamily::~SdFontFamily() { + if (ownsPointers) { + delete regular; + delete bold; + delete italic; + delete boldItalic; + } +} + +SdFontFamily::SdFontFamily(SdFontFamily&& other) noexcept + : regular(other.regular), + bold(other.bold), + italic(other.italic), + boldItalic(other.boldItalic), + ownsPointers(other.ownsPointers) { + other.regular = nullptr; + other.bold = nullptr; + other.italic = nullptr; + other.boldItalic = nullptr; + other.ownsPointers = false; +} + +SdFontFamily& SdFontFamily::operator=(SdFontFamily&& other) noexcept { + if (this != &other) { + if (ownsPointers) { + delete regular; + delete bold; + delete italic; + delete boldItalic; + } + + regular = other.regular; + bold = other.bold; + italic = other.italic; + boldItalic = other.boldItalic; + ownsPointers = other.ownsPointers; + + other.regular = nullptr; + other.bold = nullptr; + other.italic = nullptr; + other.boldItalic = nullptr; + other.ownsPointers = false; + } + return *this; +} + +bool SdFontFamily::load() { + bool success = true; + + if (regular && !regular->load()) { + Serial.printf("[%lu] [SdFontFamily] Failed to load regular font\n", millis()); + success = false; + } + if (bold && !bold->load()) { + Serial.printf("[%lu] [SdFontFamily] Failed to load bold font\n", millis()); + // Bold is optional, don't fail completely + } + if (italic && !italic->load()) { + Serial.printf("[%lu] [SdFontFamily] Failed to load italic font\n", millis()); + // Italic is optional + } + if (boldItalic && !boldItalic->load()) { + Serial.printf("[%lu] [SdFontFamily] Failed to load bold-italic font\n", millis()); + // Bold-italic is optional + } + + return success; +} + +bool SdFontFamily::isLoaded() const { return regular && regular->isLoaded(); } + +SdFont* SdFontFamily::getFont(EpdFontStyle style) const { + if (style == BOLD && bold && bold->isLoaded()) { + return bold; + } + if (style == ITALIC && italic && italic->isLoaded()) { + return italic; + } + if (style == BOLD_ITALIC) { + if (boldItalic && boldItalic->isLoaded()) { + return boldItalic; + } + if (bold && bold->isLoaded()) { + return bold; + } + if (italic && italic->isLoaded()) { + return italic; + } + } + + return regular; +} + +void SdFontFamily::getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style) const { + SdFont* font = getFont(style); + if (font) { + font->getTextDimensions(string, w, h); + } else { + *w = 0; + *h = 0; + } +} + +bool SdFontFamily::hasPrintableChars(const char* string, EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->hasPrintableChars(string) : false; +} + +const EpdGlyph* SdFontFamily::getGlyph(uint32_t cp, EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->getGlyph(cp) : nullptr; +} + +const uint8_t* SdFontFamily::getGlyphBitmap(uint32_t cp, EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->getGlyphBitmap(cp) : nullptr; +} + +uint8_t SdFontFamily::getAdvanceY(EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->getAdvanceY() : 0; +} + +int8_t SdFontFamily::getAscender(EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->getAscender() : 0; +} + +int8_t SdFontFamily::getDescender(EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->getDescender() : 0; +} + +bool SdFontFamily::is2Bit(EpdFontStyle style) const { + SdFont* font = getFont(style); + return font ? font->is2Bit() : false; +} + +// ============================================================================ +// UnifiedFontFamily Implementation +// ============================================================================ + +UnifiedFontFamily::UnifiedFontFamily(const EpdFontFamily* font) : type(Type::FLASH), flashFont(font), sdFont(nullptr) {} + +UnifiedFontFamily::UnifiedFontFamily(SdFontFamily* font) : type(Type::SD), flashFont(nullptr), sdFont(font) {} + +UnifiedFontFamily::~UnifiedFontFamily() { + // flashFont is not owned (points to global), don't delete + delete sdFont; +} + +UnifiedFontFamily::UnifiedFontFamily(UnifiedFontFamily&& other) noexcept + : type(other.type), flashFont(other.flashFont), sdFont(other.sdFont) { + other.flashFont = nullptr; + other.sdFont = nullptr; +} + +UnifiedFontFamily& UnifiedFontFamily::operator=(UnifiedFontFamily&& other) noexcept { + if (this != &other) { + // flashFont is not owned (points to global), don't delete + delete sdFont; + + type = other.type; + flashFont = other.flashFont; + sdFont = other.sdFont; + + other.flashFont = nullptr; + other.sdFont = nullptr; + } + return *this; +} + +void UnifiedFontFamily::getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + flashFont->getTextDimensions(string, w, h, style); + } else if (sdFont) { + sdFont->getTextDimensions(string, w, h, style); + } else { + *w = 0; + *h = 0; + } +} + +bool UnifiedFontFamily::hasPrintableChars(const char* string, EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + return flashFont->hasPrintableChars(string, style); + } else if (sdFont) { + return sdFont->hasPrintableChars(string, style); + } + return false; +} + +const EpdGlyph* UnifiedFontFamily::getGlyph(uint32_t cp, EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + return flashFont->getGlyph(cp, style); + } else if (sdFont) { + return sdFont->getGlyph(cp, style); + } + return nullptr; +} + +const uint8_t* UnifiedFontFamily::getGlyphBitmap(uint32_t cp, EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + // For flash fonts, get bitmap from the data structure + const EpdFontData* data = flashFont->getData(style); + const EpdGlyph* glyph = flashFont->getGlyph(cp, style); + if (data && glyph) { + return &data->bitmap[glyph->dataOffset]; + } + return nullptr; + } else if (sdFont) { + return sdFont->getGlyphBitmap(cp, style); + } + return nullptr; +} + +uint8_t UnifiedFontFamily::getAdvanceY(EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + const EpdFontData* data = flashFont->getData(style); + return data ? data->advanceY : 0; + } else if (sdFont) { + return sdFont->getAdvanceY(style); + } + return 0; +} + +int8_t UnifiedFontFamily::getAscender(EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + const EpdFontData* data = flashFont->getData(style); + return data ? data->ascender : 0; + } else if (sdFont) { + return sdFont->getAscender(style); + } + return 0; +} + +int8_t UnifiedFontFamily::getDescender(EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + const EpdFontData* data = flashFont->getData(style); + return data ? data->descender : 0; + } else if (sdFont) { + return sdFont->getDescender(style); + } + return 0; +} + +bool UnifiedFontFamily::is2Bit(EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + const EpdFontData* data = flashFont->getData(style); + return data ? data->is2Bit : false; + } else if (sdFont) { + return sdFont->is2Bit(style); + } + return false; +} + +const EpdFontData* UnifiedFontFamily::getFlashData(EpdFontStyle style) const { + if (type == Type::FLASH && flashFont) { + return flashFont->getData(style); + } + return nullptr; +} + +bool UnifiedFontFamily::hasBold() const { + if (type == Type::FLASH && flashFont) { + return flashFont->hasBold(); + } else if (sdFont) { + return sdFont->hasBold(); + } + return false; +} diff --git a/lib/EpdFont/SdFontFamily.h b/lib/EpdFont/SdFontFamily.h new file mode 100644 index 00000000..9b6e16d1 --- /dev/null +++ b/lib/EpdFont/SdFontFamily.h @@ -0,0 +1,115 @@ +#pragma once + +#include "EpdFontFamily.h" +#include "SdFont.h" + +/** + * SD Card font family - similar interface to EpdFontFamily but uses SdFont. + * Supports regular, bold, italic, and bold-italic variants. + */ +class SdFontFamily { + private: + SdFont* regular; + SdFont* bold; + SdFont* italic; + SdFont* boldItalic; + bool ownsPointers; + + SdFont* getFont(EpdFontStyle style) const; + + public: + // Constructor with raw pointers (does not take ownership) + explicit SdFontFamily(SdFont* regular, SdFont* bold = nullptr, SdFont* italic = nullptr, SdFont* boldItalic = nullptr) + : regular(regular), bold(bold), italic(italic), boldItalic(boldItalic), ownsPointers(false) {} + + // Constructor with file paths (creates and owns SdFont objects) + explicit SdFontFamily(const char* regularPath, const char* boldPath = nullptr, const char* italicPath = nullptr, + const char* boldItalicPath = nullptr); + + ~SdFontFamily(); + + // Disable copy + SdFontFamily(const SdFontFamily&) = delete; + SdFontFamily& operator=(const SdFontFamily&) = delete; + + // Enable move + SdFontFamily(SdFontFamily&& other) noexcept; + SdFontFamily& operator=(SdFontFamily&& other) noexcept; + + // Load all fonts in the family + bool load(); + bool isLoaded() const; + + // EpdFontFamily-compatible interface + void getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style = REGULAR) const; + bool hasPrintableChars(const char* string, EpdFontStyle style = REGULAR) const; + + // Get glyph (metadata only, no bitmap) + const EpdGlyph* getGlyph(uint32_t cp, EpdFontStyle style = REGULAR) const; + + // Get glyph bitmap data (loaded on demand from SD) + const uint8_t* getGlyphBitmap(uint32_t cp, EpdFontStyle style = REGULAR) const; + + // Font metadata + uint8_t getAdvanceY(EpdFontStyle style = REGULAR) const; + int8_t getAscender(EpdFontStyle style = REGULAR) const; + int8_t getDescender(EpdFontStyle style = REGULAR) const; + bool is2Bit(EpdFontStyle style = REGULAR) const; + + // Check if bold variant is available + bool hasBold() const { return bold != nullptr; } +}; + +/** + * Unified font family that can hold either EpdFontFamily (flash) or SdFontFamily (SD card). + * This allows GfxRenderer to work with both types transparently. + */ +class UnifiedFontFamily { + public: + enum class Type { FLASH, SD }; + + private: + Type type; + const EpdFontFamily* flashFont; // Non-owning pointer for flash fonts (they're global) + SdFontFamily* sdFont; // Owned pointer for SD fonts + + public: + // Construct from flash font (EpdFontFamily) - stores pointer, does not copy + explicit UnifiedFontFamily(const EpdFontFamily* font); + + // Construct from SD font family (takes ownership) + explicit UnifiedFontFamily(SdFontFamily* font); + + ~UnifiedFontFamily(); + + // Disable copy + UnifiedFontFamily(const UnifiedFontFamily&) = delete; + UnifiedFontFamily& operator=(const UnifiedFontFamily&) = delete; + + // Enable move + UnifiedFontFamily(UnifiedFontFamily&& other) noexcept; + UnifiedFontFamily& operator=(UnifiedFontFamily&& other) noexcept; + + Type getType() const { return type; } + bool isSdFont() const { return type == Type::SD; } + + // Unified interface + void getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style = REGULAR) const; + bool hasPrintableChars(const char* string, EpdFontStyle style = REGULAR) const; + const EpdGlyph* getGlyph(uint32_t cp, EpdFontStyle style = REGULAR) const; + + // For SD fonts: get bitmap data (for flash fonts, use getData()->bitmap[offset]) + const uint8_t* getGlyphBitmap(uint32_t cp, EpdFontStyle style = REGULAR) const; + + // Metadata (common interface) + uint8_t getAdvanceY(EpdFontStyle style = REGULAR) const; + int8_t getAscender(EpdFontStyle style = REGULAR) const; + int8_t getDescender(EpdFontStyle style = REGULAR) const; + bool is2Bit(EpdFontStyle style = REGULAR) const; + + // Flash font specific (returns nullptr for SD fonts) + const EpdFontData* getFlashData(EpdFontStyle style = REGULAR) const; + + // Check if bold variant is available (for synthetic bold decision) + bool hasBold() const; +}; diff --git a/lib/EpdFont/SdFontFormat.h b/lib/EpdFont/SdFontFormat.h new file mode 100644 index 00000000..8db33450 --- /dev/null +++ b/lib/EpdFont/SdFontFormat.h @@ -0,0 +1,79 @@ +/** + * .epdfont Binary Font Format Specification + * + * This format is designed for on-demand loading from SD card + * with minimal RAM usage on embedded devices. + * + * File Layout: + * ┌─────────────────────────────────────────────────────┐ + * │ Header (32 bytes) │ + * ├─────────────────────────────────────────────────────┤ + * │ Intervals[] (intervalCount × 12 bytes) │ + * ├─────────────────────────────────────────────────────┤ + * │ Glyphs[] (glyphCount × 16 bytes) │ + * ├─────────────────────────────────────────────────────┤ + * │ Bitmap data (variable size) │ + * └─────────────────────────────────────────────────────┘ + */ + +#pragma once +#include + +// Magic number: "EPDF" in little-endian +#define EPDFONT_MAGIC 0x46445045 + +// Current format version +#define EPDFONT_VERSION 1 + +#pragma pack(push, 1) + +/** + * File header - 32 bytes + */ +struct EpdFontHeader { + uint32_t magic; // 0x46445045 ("EPDF") + uint16_t version; // Format version (1) + uint8_t is2Bit; // 1 = 2-bit grayscale, 0 = 1-bit + uint8_t reserved1; // Reserved for alignment + uint8_t advanceY; // Line height + int8_t ascender; // Max height above baseline + int8_t descender; // Max depth below baseline (negative) + uint8_t reserved2; // Reserved for alignment + uint32_t intervalCount; // Number of unicode intervals + uint32_t glyphCount; // Total number of glyphs + uint32_t intervalsOffset; // File offset to intervals array + uint32_t glyphsOffset; // File offset to glyphs array + uint32_t bitmapOffset; // File offset to bitmap data +}; + +/** + * Unicode interval - 12 bytes + * Same as EpdUnicodeInterval but with explicit packing + */ +struct EpdFontInterval { + uint32_t first; // First unicode code point + uint32_t last; // Last unicode code point + uint32_t offset; // Index into glyph array +}; + +/** + * Glyph data - 16 bytes + * Same as EpdGlyph but with explicit packing + */ +struct EpdFontGlyph { + uint8_t width; // Bitmap width in pixels + uint8_t height; // Bitmap height in pixels + uint8_t advanceX; // Horizontal advance + uint8_t reserved; // Reserved for alignment + int16_t left; // X offset from cursor + int16_t top; // Y offset from cursor + uint32_t dataLength; // Bitmap data size in bytes + uint32_t dataOffset; // Offset into bitmap section +}; + +#pragma pack(pop) + +// Sanity checks for struct sizes +static_assert(sizeof(EpdFontHeader) == 32, "EpdFontHeader must be 32 bytes"); +static_assert(sizeof(EpdFontInterval) == 12, "EpdFontInterval must be 12 bytes"); +static_assert(sizeof(EpdFontGlyph) == 16, "EpdFontGlyph must be 16 bytes"); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 7072fed8..d4ad97f2 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2,7 +2,40 @@ #include -void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } +void GfxRenderer::insertFont(const int fontId, const EpdFontFamily* font) { + fontMap[fontId] = std::unique_ptr(new UnifiedFontFamily(font)); +} + +void GfxRenderer::insertSdFont(const int fontId, SdFontFamily* font) { + fontMap[fontId] = std::unique_ptr(new UnifiedFontFamily(font)); +} + +bool GfxRenderer::removeFont(const int fontId) { + auto it = fontMap.find(fontId); + if (it == fontMap.end()) { + return false; + } + fontMap.erase(it); + Serial.printf("[%lu] [GFX] Removed font %d\n", millis(), fontId); + return true; +} + +int GfxRenderer::getEffectiveFontId(const int fontId) const { + if (fontMap.find(fontId) != fontMap.end()) { + return fontId; + } + // Custom font IDs are negative (hash-based), map to CUSTOM_FONT_ID slot (-999999) + constexpr int CUSTOM_FONT_ID = -999999; + if (fontId < 0 && fontMap.find(CUSTOM_FONT_ID) != fontMap.end()) { + return CUSTOM_FONT_ID; + } + // Font not found, return fallback + if (fallbackFontId != 0 && fontMap.find(fallbackFontId) != fontMap.end()) { + return fallbackFontId; + } + // No fallback set or fallback not found, return original (will fail gracefully) + return fontId; +} void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const { switch (orientation) { @@ -66,26 +99,46 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { } } -int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { - if (fontMap.count(fontId) == 0) { - Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); +int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontStyle style) const { + const int effectiveId = getEffectiveFontId(fontId); + auto it = fontMap.find(effectiveId); + if (it == fontMap.end()) { + Serial.printf("[%lu] [GFX] Font %d not found (no fallback)\n", millis(), fontId); return 0; } int w = 0, h = 0; - fontMap.at(fontId).getTextDimensions(text, &w, &h, style); + it->second->getTextDimensions(text, &w, &h, style); + + // Add 1px per character for synthetic bold (bold requested but no bold font) + const bool syntheticBold = (style == BOLD || style == BOLD_ITALIC) && !it->second->hasBold(); + if (syntheticBold && text != nullptr) { + // Count UTF-8 characters + const uint8_t* ptr = reinterpret_cast(text); + int charCount = 0; + while (*ptr) { + // Count UTF-8 start bytes (not continuation bytes 10xxxxxx) + if ((*ptr & 0xC0) != 0x80) { + charCount++; + } + ptr++; + } + w += charCount; + } + return w; } void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black, - const EpdFontFamily::Style style) const { + const EpdFontStyle style) const { const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2; drawText(fontId, x, y, text, black, style); } void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, - const EpdFontFamily::Style style) const { - const int yPos = y + getFontAscenderSize(fontId); + const EpdFontStyle style) const { + const int effectiveId = getEffectiveFontId(fontId); + const int yPos = y + getFontAscenderSize(effectiveId); int xpos = x; // cannot draw a NULL / empty string @@ -93,11 +146,12 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha return; } - if (fontMap.count(fontId) == 0) { - Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + auto it = fontMap.find(effectiveId); + if (it == fontMap.end()) { + Serial.printf("[%lu] [GFX] Font %d not found (no fallback)\n", millis(), fontId); return; } - const auto font = fontMap.at(fontId); + const auto& font = *(it->second); // no printable characters if (!font.hasPrintableChars(text, style)) { @@ -401,7 +455,7 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, - const EpdFontFamily::Style style) const { + const EpdFontStyle style) const { std::string item = text; int itemWidth = getTextWidth(fontId, item.c_str(), style); while (itemWidth > maxWidth && item.length() > 8) { @@ -441,37 +495,41 @@ int GfxRenderer::getScreenHeight() const { } int GfxRenderer::getSpaceWidth(const int fontId) const { - if (fontMap.count(fontId) == 0) { - Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + const int effectiveId = getEffectiveFontId(fontId); + auto it = fontMap.find(effectiveId); + if (it == fontMap.end()) { + Serial.printf("[%lu] [GFX] Font %d not found (no fallback)\n", millis(), fontId); return 0; } - return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX; + const EpdGlyph* glyph = it->second->getGlyph(' ', REGULAR); + return glyph ? glyph->advanceX : 0; } int GfxRenderer::getFontAscenderSize(const int fontId) const { - if (fontMap.count(fontId) == 0) { - Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + const int effectiveId = getEffectiveFontId(fontId); + auto it = fontMap.find(effectiveId); + if (it == fontMap.end()) { + Serial.printf("[%lu] [GFX] Font %d not found (no fallback)\n", millis(), fontId); return 0; } - return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender; + return it->second->getAscender(REGULAR); } int GfxRenderer::getLineHeight(const int fontId) const { - if (fontMap.count(fontId) == 0) { - Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + const int effectiveId = getEffectiveFontId(fontId); + auto it = fontMap.find(effectiveId); + if (it == fontMap.end()) { + Serial.printf("[%lu] [GFX] Font %d not found (no fallback)\n", millis(), fontId); return 0; } - return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY; + return it->second->getAdvanceY(REGULAR); } void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, - const char* btn4) { - const Orientation orig_orientation = getOrientation(); - setOrientation(Orientation::Portrait); - + const char* btn4) const { const int pageHeight = getScreenHeight(); constexpr int buttonWidth = 106; constexpr int buttonHeight = 40; @@ -484,15 +542,12 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char // Only draw if the label is non-empty if (labels[i] != nullptr && labels[i][0] != '\0') { const int x = buttonPositions[i]; - fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); const int textWidth = getTextWidth(fontId, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); } } - - setOrientation(orig_orientation); } void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { @@ -551,7 +606,7 @@ int GfxRenderer::getTextHeight(const int fontId) const { Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); return 0; } - return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender; + return fontMap.at(fontId)->getAscender(EpdFontFamily::REGULAR); } void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black, @@ -565,10 +620,10 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); return; } - const auto font = fontMap.at(fontId); + const auto& font = fontMap.at(fontId); // No printable characters - if (!font.hasPrintableChars(text, style)) { + if (!font->hasPrintableChars(text, style)) { return; } @@ -580,22 +635,22 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y uint32_t cp; while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { - const EpdGlyph* glyph = font.getGlyph(cp, style); + const EpdGlyph* glyph = font->getGlyph(cp, style); if (!glyph) { - glyph = font.getGlyph('?', style); + glyph = font->getGlyph('?', style); } if (!glyph) { continue; } - const int is2Bit = font.getData(style)->is2Bit; - const uint32_t offset = glyph->dataOffset; + const bool is2BitFont = font->is2Bit(style); const uint8_t width = glyph->width; const uint8_t height = glyph->height; const int left = glyph->left; const int top = glyph->top; + const int ascender = font->getAscender(style); - const uint8_t* bitmap = &font.getData(style)->bitmap[offset]; + const uint8_t* bitmap = font->getGlyphBitmap(cp, style); if (bitmap != nullptr) { for (int glyphY = 0; glyphY < height; glyphY++) { @@ -605,10 +660,10 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y // 90° clockwise rotation transformation: // screenX = x + (ascender - top + glyphY) // screenY = yPos - (left + glyphX) - const int screenX = x + (font.getData(style)->ascender - top + glyphY); + const int screenX = x + (ascender - top + glyphY); const int screenY = yPos - left - glyphX; - if (is2Bit) { + if (is2BitFont) { const uint8_t byte = bitmap[pixelPosition / 4]; const uint8_t bit_index = (3 - pixelPosition % 4) * 2; const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3; @@ -745,6 +800,38 @@ void GfxRenderer::restoreBwBuffer() { Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); } +/** + * Copy stored BW buffer to framebuffer without freeing the stored chunks. + * Use this when you want to restore the buffer but keep it for later reuse. + * Returns true if buffer was copied successfully. + */ +bool GfxRenderer::copyStoredBwBuffer() { + // Check if all chunks are allocated + for (const auto& bwBufferChunk : bwBufferChunks) { + if (!bwBufferChunk) { + return false; + } + } + + uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { + const size_t offset = i * BW_BUFFER_CHUNK_SIZE; + memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); + } + + return true; +} + +/** + * Free the stored BW buffer chunks manually. + * Use this when you no longer need the stored buffer. + */ +void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); } + /** * Cleanup grayscale buffers using the current frame buffer. * Use this when BW buffer was re-rendered instead of stored/restored. @@ -756,11 +843,11 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { } } -void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, - const bool pixelState, const EpdFontFamily::Style style) const { +void GfxRenderer::renderChar(const UnifiedFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, + const bool pixelState, const EpdFontStyle style) const { const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); if (!glyph) { - // TODO: Replace with fallback glyph property? + // Try fallback glyph glyph = fontFamily.getGlyph('?', style); } @@ -770,14 +857,20 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, return; } - const int is2Bit = fontFamily.getData(style)->is2Bit; - const uint32_t offset = glyph->dataOffset; + const bool is2Bit = fontFamily.is2Bit(style); const uint8_t width = glyph->width; const uint8_t height = glyph->height; const int left = glyph->left; - const uint8_t* bitmap = nullptr; - bitmap = &fontFamily.getData(style)->bitmap[offset]; + // Check if we need synthetic bold (bold requested but no bold font available) + const bool syntheticBold = (style == BOLD || style == BOLD_ITALIC) && !fontFamily.hasBold(); + + // Get bitmap data (works for both flash and SD fonts) + const uint8_t* bitmap = fontFamily.getGlyphBitmap(cp, style); + if (!bitmap) { + // Try fallback + bitmap = fontFamily.getGlyphBitmap('?', style); + } if (bitmap != nullptr) { for (int glyphY = 0; glyphY < height; glyphY++) { @@ -797,13 +890,22 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, if (renderMode == BW && bmpVal < 3) { // Black (also paints over the grays in BW mode) drawPixel(screenX, screenY, pixelState); + if (syntheticBold) { + drawPixel(screenX + 1, screenY, pixelState); // Draw again 1px to the right + } } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { // Light gray (also mark the MSB if it's going to be a dark gray too) // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update drawPixel(screenX, screenY, false); + if (syntheticBold) { + drawPixel(screenX + 1, screenY, false); + } } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { // Dark gray drawPixel(screenX, screenY, false); + if (syntheticBold) { + drawPixel(screenX + 1, screenY, false); + } } } else { const uint8_t byte = bitmap[pixelPosition / 8]; @@ -811,13 +913,17 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, if ((byte >> bit_index) & 1) { drawPixel(screenX, screenY, pixelState); + if (syntheticBold) { + drawPixel(screenX + 1, screenY, pixelState); + } } } } } } - *x += glyph->advanceX; + // Advance by glyph width, adding 1px for synthetic bold + *x += glyph->advanceX + (syntheticBold ? 1 : 0); } void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b1fea69b..ac3f970b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -1,9 +1,10 @@ #pragma once #include -#include +#include #include +#include #include "Bitmap.h" @@ -29,9 +30,10 @@ class GfxRenderer { RenderMode renderMode; Orientation orientation; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; - std::map fontMap; - void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, - EpdFontFamily::Style style) const; + std::map> fontMap; + int fallbackFontId = 0; // Default fallback font ID (set after fonts are loaded) + void renderChar(const UnifiedFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, + EpdFontStyle style) const; void freeBwBufferChunks(); void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; @@ -44,8 +46,18 @@ class GfxRenderer { static constexpr int VIEWABLE_MARGIN_BOTTOM = 3; static constexpr int VIEWABLE_MARGIN_LEFT = 3; - // Setup - void insertFont(int fontId, EpdFontFamily font); + // Setup - Flash fonts (EpdFontFamily) - stores pointer to global font + void insertFont(int fontId, const EpdFontFamily* font); + // Setup - SD card fonts (SdFontFamily) - takes ownership + void insertSdFont(int fontId, SdFontFamily* font); + // Set fallback font ID (used when requested font is not found) + void setFallbackFont(int fontId) { fallbackFontId = fontId; } + // Check if a font is registered + bool hasFont(int fontId) const { return fontMap.find(fontId) != fontMap.end(); } + // Remove a font from the registry (frees memory for SD fonts) + bool removeFont(int fontId); + // Get effective font ID (returns fallback if requested font not found) + int getEffectiveFontId(int fontId) const; // Orientation control (affects logical width/height and coordinate transforms) void setOrientation(const Orientation o) { orientation = o; } @@ -72,19 +84,16 @@ class GfxRenderer { void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text - int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; - void drawCenteredText(int fontId, int y, const char* text, bool black = true, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; - void drawText(int fontId, int x, int y, const char* text, bool black = true, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; + void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; + void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; int getSpaceWidth(int fontId) const; int getFontAscenderSize(int fontId) const; int getLineHeight(int fontId) const; - std::string truncatedText(int fontId, const char* text, int maxWidth, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + std::string truncatedText(int fontId, const char* text, int maxWidth, EpdFontStyle style = REGULAR) const; // UI Components - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); + void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; private: @@ -99,8 +108,10 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); // Restore and free the stored buffer + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer + bool copyStoredBwBuffer(); // Copy stored buffer to framebuffer without freeing + void freeStoredBwBuffer(); // Free the stored buffer manually void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 17b5d053..55a53c2e 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 18; +constexpr uint8_t SETTINGS_COUNT = 19; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -48,6 +48,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); + serialization::writeString(outputFile, std::string(customFontPath)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -116,6 +117,13 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); if (++settingsRead >= fileSettingsCount) break; + { + std::string fontPathStr; + serialization::readString(inputFile, fontPathStr); + strncpy(customFontPath, fontPathStr.c_str(), sizeof(customFontPath) - 1); + customFontPath[sizeof(customFontPath) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); @@ -192,6 +200,19 @@ int CrossPointSettings::getRefreshFrequency() const { } int CrossPointSettings::getReaderFontId() const { + // Return custom font ID if a custom font is configured + if (hasCustomFont()) { + // Generate unique negative ID based on font path hash + // This ensures different custom fonts have different IDs for cache invalidation + uint32_t hash = 5381; + for (const char* p = customFontPath; *p; p++) { + hash = ((hash << 5) + hash) + static_cast(*p); // djb2 hash + } + // Return negative value to avoid collision with built-in font IDs + return -static_cast((hash & 0x7FFFFFFF) | 1); + } + + // Use built-in font based on fontFamily/fontSize switch (fontFamily) { case BOOKERLY: default: @@ -232,3 +253,21 @@ int CrossPointSettings::getReaderFontId() const { } } } + +const char* CrossPointSettings::getCustomFontName() const { + if (!hasCustomFont()) { + return nullptr; + } + // Extract filename from path (e.g., "/.crosspoint/fonts/MyFont.epdfont" -> "MyFont") + const char* lastSlash = strrchr(customFontPath, '/'); + const char* filename = lastSlash ? lastSlash + 1 : customFontPath; + // Remove extension for display + static char nameBuffer[32]; + strncpy(nameBuffer, filename, sizeof(nameBuffer) - 1); + nameBuffer[sizeof(nameBuffer) - 1] = '\0'; + char* dot = strrchr(nameBuffer, '.'); + if (dot) { + *dot = '\0'; + } + return nameBuffer; +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index a5641aad..631a4c1a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -92,9 +92,16 @@ class CrossPointSettings { uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // Custom reader font path (empty means use built-in font based on fontFamily/fontSize) + char customFontPath[64] = ""; ~CrossPointSettings() = default; + // Check if custom font is set + bool hasCustomFont() const { return customFontPath[0] != '\0'; } + // Get custom font name (extracted from path) + const char* getCustomFontName() const; + // Get singleton instance static CrossPointSettings& getInstance() { return instance; } diff --git a/src/FontManager.h b/src/FontManager.h new file mode 100644 index 00000000..6148c34b --- /dev/null +++ b/src/FontManager.h @@ -0,0 +1,11 @@ +#pragma once + +class GfxRenderer; + +// Reload custom reader font - removes old font and loads new one +// Call this when font settings change to apply immediately without reboot +// Returns true if custom font was loaded successfully +bool reloadCustomReaderFont(); + +// Get reference to global renderer (for font operations from other modules) +GfxRenderer& getGlobalRenderer(); diff --git a/src/activities/settings/FontSelectionActivity.cpp b/src/activities/settings/FontSelectionActivity.cpp new file mode 100644 index 00000000..5e88284d --- /dev/null +++ b/src/activities/settings/FontSelectionActivity.cpp @@ -0,0 +1,335 @@ +#include "FontSelectionActivity.h" + +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "FontManager.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr const char* DEFAULT_FONT_NAME = "Default"; +constexpr const char* CACHE_DIR = "/.crosspoint/cache"; + +// Recursively delete a directory and its contents +void deleteDirectory(const char* path) { + FsFile dir = SdMan.open(path); + if (!dir || !dir.isDir()) { + if (dir) dir.close(); + return; + } + + FsFile entry; + while (entry.openNext(&dir, O_RDONLY)) { + char entryName[64]; + entry.getName(entryName, sizeof(entryName)); + entry.close(); + + std::string fullPath = std::string(path) + "/" + entryName; + FsFile check = SdMan.open(fullPath.c_str()); + if (check) { + bool isDir = check.isDir(); + check.close(); + if (isDir) { + deleteDirectory(fullPath.c_str()); + } else { + SdMan.remove(fullPath.c_str()); + } + } + } + dir.close(); + SdMan.rmdir(path); +} + +// Invalidate rendering caches for EPUB and TXT readers +// Keeps progress.bin (reading position) but removes layout caches +void invalidateReaderCaches() { + Serial.printf("[%lu] [FNT] Invalidating reader rendering caches...\n", millis()); + + FsFile cacheDir = SdMan.open(CACHE_DIR); + if (!cacheDir || !cacheDir.isDir()) { + if (cacheDir) cacheDir.close(); + Serial.printf("[%lu] [FNT] No cache directory found\n", millis()); + return; + } + + int deletedCount = 0; + FsFile bookCache; + while (bookCache.openNext(&cacheDir, O_RDONLY)) { + char bookCacheName[64]; + bookCache.getName(bookCacheName, sizeof(bookCacheName)); + bookCache.close(); + + std::string bookCachePath = std::string(CACHE_DIR) + "/" + bookCacheName; + + // For EPUB: delete sections/ folder (keeps progress.bin) + std::string sectionsPath = bookCachePath + "/sections"; + FsFile sectionsDir = SdMan.open(sectionsPath.c_str()); + if (sectionsDir && sectionsDir.isDir()) { + sectionsDir.close(); + deleteDirectory(sectionsPath.c_str()); + Serial.printf("[%lu] [FNT] Deleted EPUB sections cache: %s\n", millis(), sectionsPath.c_str()); + deletedCount++; + } else { + if (sectionsDir) sectionsDir.close(); + } + + // For TXT: delete index.bin (keeps progress.bin) + std::string indexPath = bookCachePath + "/index.bin"; + if (SdMan.exists(indexPath.c_str())) { + SdMan.remove(indexPath.c_str()); + Serial.printf("[%lu] [FNT] Deleted TXT index cache: %s\n", millis(), indexPath.c_str()); + deletedCount++; + } + } + cacheDir.close(); + + Serial.printf("[%lu] [FNT] Invalidated %d cache entries\n", millis(), deletedCount); +} +} // namespace + +void FontSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void FontSelectionActivity::loadFontList() { + fontFiles.clear(); + fontNames.clear(); + + // First entry is always the default font (empty path means default) + fontFiles.emplace_back(""); + fontNames.emplace_back(DEFAULT_FONT_NAME); + + // Ensure fonts directory exists + SdMan.mkdir("/.crosspoint"); + SdMan.mkdir(FONTS_DIR); + + // Try to open the fonts folder + FsFile dir = SdMan.open(FONTS_DIR); + if (!dir) { + Serial.printf("[%lu] [FNT] Font folder %s not found\n", millis(), FONTS_DIR); + return; + } + + if (!dir.isDir()) { + Serial.printf("[%lu] [FNT] %s is not a directory\n", millis(), FONTS_DIR); + dir.close(); + return; + } + + // List all .epdfont files + FsFile file; + while (file.openNext(&dir, O_RDONLY)) { + if (!file.isDir()) { + char filename[64]; + file.getName(filename, sizeof(filename)); + + // Check if file has .epdfont extension and skip macOS hidden files (._*) + const size_t len = strlen(filename); + if (len > 8 && strcasecmp(filename + len - 8, ".epdfont") == 0 && strncmp(filename, "._", 2) != 0) { + // Build full path + std::string fullPath = std::string(FONTS_DIR) + "/" + filename; + fontFiles.push_back(fullPath); + + // Extract name without extension for display + std::string displayName(filename, len - 8); + fontNames.push_back(displayName); + + Serial.printf("[%lu] [FNT] Found font: %s\n", millis(), fullPath.c_str()); + } + } + file.close(); + } + dir.close(); + + Serial.printf("[%lu] [FNT] Total fonts found: %zu (including default)\n", millis(), fontFiles.size()); + + // Find currently selected font index + selectedIndex = 0; // Default + if (SETTINGS.hasCustomFont()) { + for (size_t i = 1; i < fontFiles.size(); i++) { + if (fontFiles[i] == SETTINGS.customFontPath) { + selectedIndex = static_cast(i); + break; + } + } + } +} + +void FontSelectionActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Load font list from SD card + loadFontList(); + + updateRequired = true; + + xTaskCreate(&FontSelectionActivity::taskTrampoline, "FontSelectionTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void FontSelectionActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void FontSelectionActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + const int itemCount = static_cast(fontNames.size()); + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex + itemCount - 1) % itemCount; + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % itemCount; + updateRequired = true; + } +} + +void FontSelectionActivity::handleSelection() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + // Show loading screen + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, renderer.getScreenHeight() / 2 - 10, "Applying font..."); + renderer.displayBuffer(); + + // Update custom font path in settings + if (selectedIndex == 0) { + // Default font selected - clear custom font path + SETTINGS.customFontPath[0] = '\0'; + } else { + // Custom font selected + strncpy(SETTINGS.customFontPath, fontFiles[selectedIndex].c_str(), sizeof(SETTINGS.customFontPath) - 1); + SETTINGS.customFontPath[sizeof(SETTINGS.customFontPath) - 1] = '\0'; + } + + SETTINGS.saveToFile(); + Serial.printf("[%lu] [FNT] Font selected: %s\n", millis(), selectedIndex == 0 ? "default" : SETTINGS.customFontPath); + + // Reload custom font dynamically (no reboot needed) + reloadCustomReaderFont(); + + // Invalidate EPUB/TXT caches since font changed + invalidateReaderCaches(); + + xSemaphoreGive(renderingMutex); + + // Return to settings + onBack(); +} + +void FontSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void FontSelectionActivity::render() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Custom Font", true, EpdFontFamily::BOLD); + + // Calculate visible items (with scrolling if needed) + constexpr int lineHeight = 30; + constexpr int startY = 60; + const int maxVisibleItems = (pageHeight - startY - 50) / lineHeight; + const int itemCount = static_cast(fontNames.size()); + + // Calculate scroll offset to keep selected item visible + int scrollOffset = 0; + if (itemCount > maxVisibleItems) { + if (selectedIndex >= maxVisibleItems) { + scrollOffset = selectedIndex - maxVisibleItems + 1; + } + } + + // Determine current selection (for checkmark comparison) + int currentSelectedIndex = 0; // Default + if (SETTINGS.hasCustomFont()) { + for (size_t i = 1; i < fontFiles.size(); i++) { + if (fontFiles[i] == SETTINGS.customFontPath) { + currentSelectedIndex = static_cast(i); + break; + } + } + } + + // Draw font list + for (int i = 0; i < maxVisibleItems && (i + scrollOffset) < itemCount; i++) { + const int itemIndex = i + scrollOffset; + const int itemY = startY + i * lineHeight; + const bool isHighlighted = (itemIndex == selectedIndex); + const bool isCurrentFont = (itemIndex == currentSelectedIndex); + + // Draw selection highlight + if (isHighlighted) { + renderer.fillRect(0, itemY - 2, pageWidth - 1, lineHeight); + } + + // Draw checkmark for currently active font (using asterisk - available in Pretendard) + if (isCurrentFont) { + renderer.drawText(UI_10_FONT_ID, 10, itemY, "*", !isHighlighted); + } + + // Draw font name + renderer.drawText(UI_10_FONT_ID, 35, itemY, fontNames[itemIndex].c_str(), !isHighlighted); + } + + // Draw scroll indicators if needed + if (scrollOffset > 0) { + renderer.drawCenteredText(UI_10_FONT_ID, startY - 15, "...", true); + } + if (scrollOffset + maxVisibleItems < itemCount) { + renderer.drawCenteredText(UI_10_FONT_ID, startY + maxVisibleItems * lineHeight, "...", true); + } + + // Draw help text + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/FontSelectionActivity.h b/src/activities/settings/FontSelectionActivity.h new file mode 100644 index 00000000..c04b6190 --- /dev/null +++ b/src/activities/settings/FontSelectionActivity.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" + +/** + * Activity for selecting a custom font from /.crosspoint/fonts folder. + * Lists .bin font files and allows the user to select one. + */ +class FontSelectionActivity final : public ActivityWithSubactivity { + public: + explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack) + : ActivityWithSubactivity("FontSelection", renderer, mappedInput), onBack(onBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + int selectedIndex = 0; + std::vector fontFiles; // List of font file paths + std::vector fontNames; // Display names (without path and extension) + const std::function onBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void loadFontList(); + void handleSelection(); + + static constexpr const char* FONTS_DIR = "/.crosspoint/fonts"; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..97b6ae9d 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -7,13 +7,14 @@ #include "CalibreSettingsActivity.h" #include "CrossPointSettings.h" +#include "FontSelectionActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 20; +constexpr int settingsCount = 21; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -41,6 +42,7 @@ const SettingInfo settingsList[settingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Action("Custom Font"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace @@ -139,7 +141,15 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; } } else if (setting.type == SettingType::ACTION) { - if (strcmp(setting.name, "Calibre Settings") == 0) { + if (strcmp(setting.name, "Custom Font") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new FontSelectionActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Calibre Settings") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/fontIds.h b/src/fontIds.h index d73a536c..2da9b0d2 100644 --- a/src/fontIds.h +++ b/src/fontIds.h @@ -16,3 +16,6 @@ #define UI_10_FONT_ID (-823541435) #define UI_12_FONT_ID (-126318184) #define SMALL_FONT_ID (-874196069) + +// Custom font ID (used as slot ID in fontMap, actual ID is hash-based for cache invalidation) +#define CUSTOM_FONT_ID (-999999) diff --git a/src/main.cpp b/src/main.cpp index 8a7c3b91..cd6cac00 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -131,6 +132,67 @@ EpdFont ui12RegularFont(&ubuntu_12_regular); EpdFont ui12BoldFont(&ubuntu_12_bold); EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont); +// Custom font loading from SD card +constexpr char FONTS_DIR[] = "/.crosspoint/fonts"; + +// Load custom reader font from SD card if configured +// Returns true if custom font was loaded successfully +bool loadCustomReaderFont(GfxRenderer& gfxRenderer) { + if (!SETTINGS.hasCustomFont()) { + Serial.printf("[%lu] [FNT] No custom font configured\n", millis()); + return false; + } + + const char* fontPath = SETTINGS.customFontPath; + Serial.printf("[%lu] [FNT] Loading custom font: %s\n", millis(), fontPath); + + if (!SdMan.exists(fontPath)) { + Serial.printf("[%lu] [FNT] Custom font file not found: %s\n", millis(), fontPath); + // Clear invalid font path + SETTINGS.customFontPath[0] = '\0'; + SETTINGS.saveToFile(); + return false; + } + + // Create SdFontFamily for the custom font + SdFontFamily* font = new SdFontFamily(fontPath); + if (font == nullptr) { + Serial.printf("[%lu] [FNT] Failed to allocate memory for custom font\n", millis()); + return false; + } + + if (font->load()) { + gfxRenderer.insertSdFont(CUSTOM_FONT_ID, font); + Serial.printf("[%lu] [FNT] Custom reader font loaded successfully\n", millis()); + return true; + } + + Serial.printf("[%lu] [FNT] Failed to load custom font, clearing setting\n", millis()); + delete font; + // Clear invalid font path so getReaderFontId() returns default font + SETTINGS.customFontPath[0] = '\0'; + SETTINGS.saveToFile(); + return false; +} + +// Reload custom reader font - removes old font and loads new one +// Call this when font settings change to apply immediately without reboot +bool reloadCustomReaderFont() { + Serial.printf("[%lu] [FNT] Reloading custom reader font...\n", millis()); + + // Remove existing custom font if any + if (renderer.hasFont(CUSTOM_FONT_ID)) { + renderer.removeFont(CUSTOM_FONT_ID); + Serial.printf("[%lu] [FNT] Removed previous custom font\n", millis()); + } + + // Load new custom font if configured + return loadCustomReaderFont(renderer); +} + +// Get reference to global renderer (for font operations from other modules) +GfxRenderer& getGlobalRenderer() { return renderer; } + // measurement of power button press duration calibration value unsigned long t1 = 0; unsigned long t2 = 0; @@ -241,25 +303,34 @@ void onGoHome() { void setupDisplayAndFonts() { einkDisplay.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); - renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); -#ifndef OMIT_FONTS - renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); - renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily); - renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily); - renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); - renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); - renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); - renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily); - renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily); - renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily); - renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily); - renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily); + // Register built-in flash fonts + renderer.insertFont(BOOKERLY_14_FONT_ID, &bookerly14FontFamily); +#ifndef OMIT_FONTS + renderer.insertFont(BOOKERLY_12_FONT_ID, &bookerly12FontFamily); + renderer.insertFont(BOOKERLY_16_FONT_ID, &bookerly16FontFamily); + renderer.insertFont(BOOKERLY_18_FONT_ID, &bookerly18FontFamily); + + renderer.insertFont(NOTOSANS_12_FONT_ID, ¬osans12FontFamily); + renderer.insertFont(NOTOSANS_14_FONT_ID, ¬osans14FontFamily); + renderer.insertFont(NOTOSANS_16_FONT_ID, ¬osans16FontFamily); + renderer.insertFont(NOTOSANS_18_FONT_ID, ¬osans18FontFamily); + renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, &opendyslexic8FontFamily); + renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, &opendyslexic10FontFamily); + renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, &opendyslexic12FontFamily); + renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, &opendyslexic14FontFamily); #endif // OMIT_FONTS - renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); - renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); - renderer.insertFont(SMALL_FONT_ID, smallFontFamily); - Serial.printf("[%lu] [ ] Fonts setup\n", millis()); + renderer.insertFont(UI_10_FONT_ID, &ui10FontFamily); + renderer.insertFont(UI_12_FONT_ID, &ui12FontFamily); + renderer.insertFont(SMALL_FONT_ID, &smallFontFamily); + + // Set fallback font + renderer.setFallbackFont(UI_10_FONT_ID); + + // Try to load custom reader font from SD card + loadCustomReaderFont(renderer); + + Serial.printf("[%lu] [ ] Fonts setup complete\n", millis()); } void setup() { From c0057900ebd474365c6c7ce240f626217800b154 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 18:52:30 +0900 Subject: [PATCH 2/6] fix: Display selected custom font name in Settings --- src/activities/settings/SettingsActivity.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 97b6ae9d..6dd5596e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -216,6 +216,9 @@ void SettingsActivity::render() const { valueText = settingsList[i].enumValues[value]; } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } else if (settingsList[i].type == SettingType::ACTION && strcmp(settingsList[i].name, "Custom Font") == 0) { + // Show current custom font name or "Default" + valueText = SETTINGS.hasCustomFont() ? SETTINGS.getCustomFontName() : "Default"; } const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); From 00134bbe80d520c22917a2993bbe5e1f34a71192 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 18:56:53 +0900 Subject: [PATCH 3/6] fix: Clamp page number when out of bounds after font change When font changes cause different page counts, the cached page number may exceed the new page count. Instead of showing 'Out of bounds' error, clamp to the last page of the section. --- src/activities/reader/EpubReaderActivity.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2eeba80f..6016965b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -340,11 +340,11 @@ void EpubReaderActivity::renderScreen() { } if (section->currentPage < 0 || section->currentPage >= section->pageCount) { - Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); - renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); - renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); - renderer.displayBuffer(); - return; + // Page out of bounds - likely due to font change causing different page count + // Clamp to valid range instead of showing error + Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d), clamping to last page\n", millis(), + section->currentPage, section->pageCount); + section->currentPage = section->pageCount - 1; } { From fa4c63e54b0f07dd460398fc552762b88c923ed3 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 19:01:36 +0900 Subject: [PATCH 4/6] perf: Add binary search for TXT word wrap Use binary search instead of linear search for finding line break positions. This significantly improves TXT file indexing performance, especially with SD card fonts where getTextWidth() calls are slower. --- src/activities/reader/TxtReaderActivity.cpp | 93 ++++++++++++++++----- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index db725320..79b3829b 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -19,6 +19,70 @@ constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes + +// Find UTF-8 character boundary at or before pos +size_t findUtf8Boundary(const std::string& str, size_t pos) { + if (pos >= str.length()) return str.length(); + // Move back if we're in the middle of a UTF-8 sequence + while (pos > 0 && (str[pos] & 0xC0) == 0x80) { + pos--; + } + return pos; +} + +// Binary search to find max characters that fit in width +// Returns the position (byte offset) where to break the string +size_t findBreakPosition(GfxRenderer& renderer, int fontId, const std::string& line, int maxWidth) { + if (line.empty()) return 0; + + // First check if the whole line fits + int fullWidth = renderer.getTextWidth(fontId, line.c_str()); + if (fullWidth <= maxWidth) { + return line.length(); + } + + // Binary search for the break point + size_t low = 1; // At minimum 1 character + size_t high = line.length(); + size_t bestFit = 1; + + while (low < high) { + size_t mid = (low + high + 1) / 2; + mid = findUtf8Boundary(line, mid); + + if (mid <= low) { + // Can't make progress, exit + break; + } + + std::string substr = line.substr(0, mid); + int width = renderer.getTextWidth(fontId, substr.c_str()); + + if (width <= maxWidth) { + bestFit = mid; + low = mid; + } else { + high = mid - 1; + if (high > 0) { + high = findUtf8Boundary(line, high); + } + } + } + + // Try to break at word boundary (space) if possible + if (bestFit > 0 && bestFit < line.length()) { + size_t spacePos = line.rfind(' ', bestFit); + if (spacePos != std::string::npos && spacePos > 0) { + // Check if breaking at space still fits + std::string atSpace = line.substr(0, spacePos); + if (renderer.getTextWidth(fontId, atSpace.c_str()) <= maxWidth) { + return spacePos; + } + } + } + + return bestFit > 0 ? bestFit : 1; // At minimum, consume 1 character +} } // namespace void TxtReaderActivity::taskTrampoline(void* param) { @@ -303,36 +367,21 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector // Track position within this source line (in bytes from pos) size_t lineBytePos = 0; - // Word wrap if needed + // Word wrap if needed - use binary search for performance with SD fonts while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { - int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + // Use binary search to find break position (much faster than linear search) + size_t breakPos = findBreakPosition(renderer, cachedFontId, line, viewportWidth); - if (lineWidth <= viewportWidth) { + if (breakPos >= line.length()) { + // Whole line fits outLines.push_back(line); - lineBytePos = displayLen; // Consumed entire display content + lineBytePos = displayLen; line.clear(); break; } - // Find break point - size_t breakPos = line.length(); - while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { - // Try to break at space - size_t spacePos = line.rfind(' ', breakPos - 1); - if (spacePos != std::string::npos && spacePos > 0) { - breakPos = spacePos; - } else { - // Break at character boundary for UTF-8 - breakPos--; - // Make sure we don't break in the middle of a UTF-8 sequence - while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { - breakPos--; - } - } - } - if (breakPos == 0) { - breakPos = 1; + breakPos = 1; // Ensure progress } outLines.push_back(line.substr(0, breakPos)); From 9cd208f8ae8fdb8f6979733953920686d030b204 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 19:09:06 +0900 Subject: [PATCH 5/6] docs: Fix comment - .bin to .epdfont --- src/activities/settings/FontSelectionActivity.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/settings/FontSelectionActivity.h b/src/activities/settings/FontSelectionActivity.h index c04b6190..70ded450 100644 --- a/src/activities/settings/FontSelectionActivity.h +++ b/src/activities/settings/FontSelectionActivity.h @@ -11,7 +11,7 @@ /** * Activity for selecting a custom font from /.crosspoint/fonts folder. - * Lists .bin font files and allows the user to select one. + * Lists .epdfont files and allows the user to select one. */ class FontSelectionActivity final : public ActivityWithSubactivity { public: From 65b09171a623cded014890cf0042b484e65122ad Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 19:16:03 +0900 Subject: [PATCH 6/6] style: Add const to renderer parameter in findBreakPosition --- src/activities/reader/TxtReaderActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 79b3829b..32ce0538 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -32,7 +32,7 @@ size_t findUtf8Boundary(const std::string& str, size_t pos) { // Binary search to find max characters that fit in width // Returns the position (byte offset) where to break the string -size_t findBreakPosition(GfxRenderer& renderer, int fontId, const std::string& line, int maxWidth) { +size_t findBreakPosition(const GfxRenderer& renderer, int fontId, const std::string& line, int maxWidth) { if (line.empty()) return 0; // First check if the whole line fits