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
+156
View File
@@ -0,0 +1,156 @@
#include "FontInstaller.h"
#include <HalStorage.h>
#include <Logging.h>
#include <cctype>
#include <cstring>
#include "CrossPointSettings.h"
FontInstaller::FontInstaller(SdCardFontRegistry& registry) : registry_(registry) {}
bool FontInstaller::isValidFamilyName(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path traversal
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
for (const char* p = name; *p != '\0'; ++p) {
char c = *p;
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::isValidCpfontFilename(const char* name) {
if (name == nullptr || name[0] == '\0') return false;
// Reject path separators / traversal up front. Anything that could escape
// the family directory or refer to a different one is a hard reject.
if (strstr(name, "..") != nullptr) return false;
if (strchr(name, '/') != nullptr) return false;
if (strchr(name, '\\') != nullptr) return false;
// Must end with ".cpfont" exactly.
static constexpr char kExt[] = ".cpfont";
static constexpr size_t kExtLen = sizeof(kExt) - 1;
size_t nameLen = strlen(name);
if (nameLen <= kExtLen) return false;
if (strcmp(name + nameLen - kExtLen, kExt) != 0) return false;
// Basename (before .cpfont) must be alphanumeric + hyphen + underscore only.
// No additional dots — keeps stray "Foo.cpfont.tmp"-style names out.
size_t baseLen = nameLen - kExtLen;
for (size_t i = 0; i < baseLen; ++i) {
char c = name[i];
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
return false;
}
}
return true;
}
bool FontInstaller::ensureFamilyDir(const char* familyName) {
// Reuse the family's existing root if installed; otherwise pick the
// default-write root (hidden if no roots exist yet).
const char* root = SdCardFontRegistry::findFamilyRoot(familyName);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
if (!Storage.exists(root)) {
if (!Storage.mkdir(root)) {
LOG_ERR("FONT", "Failed to create fonts dir: %s", root);
return false;
}
}
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) {
if (!Storage.mkdir(dirPath)) {
LOG_ERR("FONT", "Failed to create family dir: %s", dirPath);
return false;
}
}
return true;
}
bool FontInstaller::validateCpfontFile(const char* path) {
FsFile file;
if (!Storage.openFileForRead("FONT", path, file)) {
LOG_ERR("FONT", "Cannot open for validation: %s", path);
return false;
}
uint8_t magic[CPFONT_MAGIC_LEN];
size_t bytesRead = file.read(magic, CPFONT_MAGIC_LEN);
file.close();
if (bytesRead < CPFONT_MAGIC_LEN) {
LOG_ERR("FONT", "File too small: %s (%zu bytes)", path, bytesRead);
return false;
}
if (memcmp(magic, "CPFONT\0\0", CPFONT_MAGIC_LEN) != 0) {
LOG_ERR("FONT", "Bad magic in: %s", path);
return false;
}
return true;
}
void FontInstaller::buildFontPath(const char* family, const char* filename, char* outBuf, size_t outBufSize) {
// Use the same root selection as ensureFamilyDir: existing install dir wins,
// otherwise the default-write root.
const char* root = SdCardFontRegistry::findFamilyRoot(family);
if (!root) root = SdCardFontRegistry::defaultWriteRoot();
snprintf(outBuf, outBufSize, "%s/%s/%s", root, family, filename);
}
FontInstaller::Error FontInstaller::deleteFamily(const char* familyName) {
if (!isValidFamilyName(familyName)) {
return Error::INVALID_FAMILY_NAME;
}
// A family may exist in either root (or, edge case, both). Remove from both.
const char* roots[] = {SdCardFontRegistry::FONTS_DIR_HIDDEN, SdCardFontRegistry::FONTS_DIR_VISIBLE};
bool removedAny = false;
bool sawAny = false;
for (const char* root : roots) {
char dirPath[160];
snprintf(dirPath, sizeof(dirPath), "%s/%s", root, familyName);
if (!Storage.exists(dirPath)) continue;
sawAny = true;
if (!Storage.removeDir(dirPath)) {
LOG_ERR("FONT", "Failed to remove family dir: %s", dirPath);
return Error::SD_WRITE_ERROR;
}
removedAny = true;
}
if (!sawAny) {
LOG_DBG("FONT", "Family not found in any fonts root: %s", familyName);
return Error::OK; // Already gone
}
(void)removedAny;
// If this was the active font, clear the setting
if (strcmp(SETTINGS.sdFontFamilyName, familyName) == 0) {
SETTINGS.sdFontFamilyName[0] = '\0';
SETTINGS.saveToFile();
LOG_DBG("FONT", "Cleared active SD font (deleted family: %s)", familyName);
}
return Error::OK;
}
void FontInstaller::refreshRegistry() { registry_.discover(); }
bool FontInstaller::isFamilyInstalled(const char* familyName) const {
return registry_.findFamily(familyName) != nullptr;
}