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
+59 -1
View File
@@ -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);