From 68ce6db2914648fd2b92ede4e762c1f2c291808b Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 18 Jan 2026 18:46:23 +0900 Subject: [PATCH] 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() {