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:
committed by
Zach Nelson
co-authored by
Zach Nelson
Justin
jpirnay
mcrosson
parent
29fd29f537
commit
7993b2bb97
@@ -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; }
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
#include <string>
|
||||
|
||||
class FontDecompressor;
|
||||
class SdCardFont;
|
||||
|
||||
class FontCacheManager {
|
||||
public:
|
||||
explicit FontCacheManager(const std::map<int, EpdFontFamily>& fontMap);
|
||||
FontCacheManager(const std::map<int, EpdFontFamily>& fontMap, const std::map<int, SdCardFont*>& sdCardFonts);
|
||||
|
||||
void setFontDecompressor(FontDecompressor* d);
|
||||
|
||||
@@ -45,6 +46,7 @@ class FontCacheManager {
|
||||
|
||||
private:
|
||||
const std::map<int, EpdFontFamily>& fontMap_;
|
||||
const std::map<int, SdCardFont*>& sdCardFonts_;
|
||||
FontDecompressor* fontDecompressor_ = nullptr;
|
||||
|
||||
enum class ScanMode : uint8_t { None, Scanning };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <FontDecompressor.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <Logging.h>
|
||||
#include <SdCardFont.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -22,9 +23,34 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
|
||||
// must consume it (draw the glyph) before requesting another bitmap.
|
||||
return fd->getBitmap(fontData, glyph, glyphIndex);
|
||||
}
|
||||
// For SD card fonts, check if the glyph was loaded on demand into the overflow
|
||||
// buffer. getOverflowBitmap() returns:
|
||||
// - bitmap pointer for overflow glyphs with bitmap data
|
||||
// - nullptr for overflow glyphs without bitmap data (e.g. space: width=0, height=0)
|
||||
// - nullptr for non-overflow glyphs (normal prewarmed path)
|
||||
// We distinguish overflow-with-no-bitmap from non-overflow by checking isOverflowGlyph().
|
||||
if (fontData->glyphMissCtx) {
|
||||
auto* sdFont = SdCardFont::fromMissCtx(fontData->glyphMissCtx);
|
||||
if (sdFont->isOverflowGlyph(glyph)) {
|
||||
return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs
|
||||
}
|
||||
}
|
||||
return &fontData->bitmap[glyph->dataOffset];
|
||||
}
|
||||
|
||||
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask) const {
|
||||
auto it = sdCardFonts_.find(fontId);
|
||||
if (it != sdCardFonts_.end()) {
|
||||
// Augment the persistent advance-only table for layout measurement.
|
||||
// The table survives across paragraphs/sections (capped per font), so
|
||||
// repeated indexing of the same SD font amortizes glyph-metric SD reads.
|
||||
int missed = it->second->buildAdvanceTable(utf8Text, styleMask);
|
||||
if (missed > 0) {
|
||||
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::begin() {
|
||||
frameBuffer = display.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
@@ -38,7 +64,12 @@ void GfxRenderer::begin() {
|
||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||
}
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
auto result = fontMap.insert({fontId, font});
|
||||
if (!result.second) {
|
||||
LOG_ERR("GFX", "Font ID %d already registered, ignoring duplicate", fontId);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
|
||||
// This should always be inlined for better performance
|
||||
@@ -1040,6 +1071,12 @@ int GfxRenderer::getScreenHeight() const {
|
||||
}
|
||||
|
||||
int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
@@ -1052,6 +1089,14 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
|
||||
|
||||
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout.
|
||||
// Kern data is not loaded during layout (consistent with previous metadataOnly behavior),
|
||||
// so we return just the space advance without kerning.
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', static_cast<uint8_t>(style)));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) return 0;
|
||||
const auto& font = fontIt->second;
|
||||
@@ -1073,6 +1118,19 @@ int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint3
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
|
||||
// Advance table fast-path for SD card fonts during layout.
|
||||
// No kerning/ligature lookup — consistent with previous metadataOnly behavior
|
||||
// where kern/lig data was not loaded.
|
||||
auto sdIt = sdCardFonts_.find(fontId);
|
||||
if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) {
|
||||
int32_t widthFP = 0;
|
||||
const uint8_t styleIdx = static_cast<uint8_t>(style);
|
||||
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
|
||||
widthFP += sdIt->second->getAdvance(cp, styleIdx);
|
||||
}
|
||||
return fp4::toPixel(widthFP);
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <HalDisplay.h>
|
||||
|
||||
class FontCacheManager;
|
||||
class SdCardFont;
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
@@ -42,6 +43,11 @@ class GfxRenderer {
|
||||
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
|
||||
std::vector<uint8_t*> bwBufferChunks;
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
// Mutable because ensureSdCardFontReady() is const (called from layout code
|
||||
// that holds a const GfxRenderer&) but triggers SD card reads and heap
|
||||
// allocation inside the SdCardFont objects. Same pragmatic compromise as
|
||||
// fontCacheManager_ below.
|
||||
mutable std::map<int, SdCardFont*> sdCardFonts_;
|
||||
|
||||
// Mutable because drawText() is const but needs to delegate scan-mode
|
||||
// recording to the (non-const) FontCacheManager. Same pragmatic compromise
|
||||
@@ -69,9 +75,25 @@ class GfxRenderer {
|
||||
// Setup
|
||||
void begin(); // must be called right after display.begin()
|
||||
void insertFont(int fontId, EpdFontFamily font);
|
||||
// Clears both the flash-font map and any SD-font registration for fontId.
|
||||
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
|
||||
// the underlying SdCardFont and forget the SD-side unregister.
|
||||
void removeFont(int fontId) {
|
||||
fontMap.erase(fontId);
|
||||
sdCardFonts_.erase(fontId);
|
||||
}
|
||||
void setFontCacheManager(FontCacheManager* m) { fontCacheManager_ = m; }
|
||||
FontCacheManager* getFontCacheManager() const { return fontCacheManager_; }
|
||||
const std::map<int, EpdFontFamily>& getFontMap() const { return fontMap; }
|
||||
void registerSdCardFont(int fontId, SdCardFont* font) { sdCardFonts_[fontId] = font; }
|
||||
void unregisterSdCardFont(int fontId) { removeFont(fontId); }
|
||||
void clearSdCardFonts() { sdCardFonts_.clear(); }
|
||||
const std::map<int, SdCardFont*>& getSdCardFonts() const { return sdCardFonts_; }
|
||||
bool isSdCardFont(int fontId) const { return sdCardFonts_.count(fontId) > 0; }
|
||||
// Ensure SD card font glyph data is loaded for the given text. Called from layout code
|
||||
// (which holds a const GfxRenderer&) before measuring word widths. Safe to call on non-SD fonts (no-op).
|
||||
// styleMask: bitmask of styles to prepare (bit 0=regular, 1=bold, 2=italic, 3=bold-italic).
|
||||
void ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F) const;
|
||||
|
||||
// Orientation control (affects logical width/height and coordinate transforms)
|
||||
void setOrientation(const Orientation o) { orientation = o; }
|
||||
|
||||
Reference in New Issue
Block a user