feat: add SD card font support with on-device download and web management

Add a complete SD card font subsystem that enables users to install and
use custom fonts beyond the three built-in families. This combines the
back-end firmware support (#1327) with the font configuration, build
pipeline, CI distribution, and user-facing management UI (#1392).

Core font system:
- Custom .cpfont binary format (v4) with multi-style support (regular,
  bold, italic, bold-italic) packed into a single file per size
- On-demand glyph loading from SD card with two-pass prewarm rendering
  to bulk-read glyphs per page, achieving near-flash performance for
  Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower)
- Persistent advance cache for layout measurement without SD I/O
- Overflow ring buffer for glyph cache misses during rendering
- Memory-conscious design: only advance tables kept in RAM; glyph
  bitmaps, kern tables, and ligatures loaded on demand from SD

Font management:
- On-device WiFi download from GitHub Releases with manifest-based
  discovery, install/update detection, and progress UI
- Web interface font upload, listing, and deletion via /fonts page
- Manual SD card copy to /fonts/ or /.fonts/ directories
- Font selection integrated into Settings > Reader > Font Family

Build pipeline:
- Declarative YAML config (sd-fonts.yaml) as single source of truth
  for the 17-family font library (serif, sans, mono, accessibility)
- Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with
  FreeType rasterization, class-based kerning, and ligature extraction
- Parallel build orchestrator with variable font instance extraction
- CI workflow publishing versioned + stable releases to a dedicated
  crosspoint-fonts repository with auto-incrementing revision tags
- Centralized version constants (cpfont_version.py) shared across
  build tooling and CI, with firmware headers as manual sync points

Additional fixes:
- CJK characters no longer get hyphens inserted at line breaks
- Advance table eliminates 30+ second stalls during CJK section
  indexing for paragraphs with >512 unique codepoints

Closes #930

Co-authored-by: Zach Nelson <zach@zdnelson.com>
Co-authored-by: Justin <itsthisjustin@users.noreply.github.com>
Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
Adrian Wilkins-Caruana
2026-05-08 21:50:06 -05:00
committed by Zach Nelson
co-authored by Zach Nelson Justin jpirnay mcrosson
parent 29fd29f537
commit 7993b2bb97
57 changed files with 6064 additions and 54 deletions
+24 -1
View File
@@ -2,18 +2,35 @@
#include <FontDecompressor.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <cstring>
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap) : fontMap_(fontMap) {}
FontCacheManager::FontCacheManager(const std::map<int, EpdFontFamily>& fontMap,
const std::map<int, SdCardFont*>& sdCardFonts)
: fontMap_(fontMap), sdCardFonts_(sdCardFonts) {}
void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; }
void FontCacheManager::clearCache() {
if (fontDecompressor_) fontDecompressor_->clearCache();
for (auto& [id, font] : sdCardFonts_) {
font->clearCache();
}
}
void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask) {
// SD card font prewarm path: prewarm all requested styles in one call
auto it = sdCardFonts_.find(fontId);
if (it != sdCardFonts_.end()) {
int missed = it->second->prewarm(utf8Text, styleMask);
if (missed > 0) {
LOG_DBG("FCM", "prewarmCache(SD): %d glyph(s) not found (styleMask=0x%02X)", missed, styleMask);
}
return;
}
// Standard compressed font prewarm path: loop over all requested styles
if (!fontDecompressor_ || fontMap_.count(fontId) == 0) return;
for (uint8_t i = 0; i < 4; i++) {
@@ -30,10 +47,16 @@ void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t st
void FontCacheManager::logStats(const char* label) {
if (fontDecompressor_) fontDecompressor_->logStats(label);
for (auto& [id, font] : sdCardFonts_) {
font->logStats(label);
}
}
void FontCacheManager::resetStats() {
if (fontDecompressor_) fontDecompressor_->resetStats();
for (auto& [id, font] : sdCardFonts_) {
font->resetStats();
}
}
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }