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
+101 -3
View File
@@ -2,18 +2,105 @@
#include <HalTiltSensor.h>
#include <I18n.h>
#include <SdCardFontRegistry.h>
#include <algorithm>
#include <cstring>
#include <iterator>
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Build the font family setting dynamically. When registry is non-null, SD card fonts
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
// Built-in font labels (StrId)
std::vector<StrId> enumValues = {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC};
// Runtime string labels for SD card fonts
std::vector<std::string> enumStringValues;
// Reserve: first CrossPointSettings::BUILTIN_FONT_COUNT entries use StrId, rest use strings
if (registry) {
const auto& families = registry->getFamilies();
enumStringValues.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(enumStringValues),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
// Capture the SD font count for the lambdas
const int sdFontCount = static_cast<int>(enumStringValues.size());
// Total option count = built-in + SD card families
// For the combined enumStringValues: we need all entries as strings (built-in names + SD names)
// The render code checks enumStringValues first, then enumValues. So we build enumStringValues
// with all options when SD fonts are present.
std::vector<std::string> allStringValues;
if (sdFontCount > 0) {
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SERIF));
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SANS));
allStringValues.push_back(I18N.get(StrId::STR_OPEN_DYSLEXIC));
allStringValues.insert(allStringValues.end(), enumStringValues.begin(), enumStringValues.end());
}
SettingInfo s;
s.nameId = StrId::STR_FONT_FAMILY;
s.type = SettingType::ENUM;
s.enumValues = std::move(enumValues);
s.enumStringValues = std::move(allStringValues);
s.key = "fontFamily";
s.category = StrId::STR_CAT_READER;
// Capture registry families by copy for the lambdas
std::vector<std::string> sdFamilyNames;
if (registry) {
const auto& families = registry->getFamilies();
sdFamilyNames.reserve(families.size());
std::transform(families.begin(), families.end(), std::back_inserter(sdFamilyNames),
[](const SdCardFontFamilyInfo& f) { return f.name; });
}
s.valueGetter = [sdFamilyNames]() -> uint8_t {
// If an SD card font is selected, find its index
if (SETTINGS.sdFontFamilyName[0] != '\0') {
for (int i = 0; i < static_cast<int>(sdFamilyNames.size()); i++) {
if (sdFamilyNames[i] == SETTINGS.sdFontFamilyName) {
return static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i);
}
}
// SD font name not found in registry — fall through to built-in
}
return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
};
s.valueSetter = [sdFamilyNames](uint8_t v) {
if (v < CrossPointSettings::BUILTIN_FONT_COUNT) {
SETTINGS.fontFamily = v;
SETTINGS.sdFontFamilyName[0] = '\0';
} else {
int sdIdx = v - CrossPointSettings::BUILTIN_FONT_COUNT;
if (sdIdx < static_cast<int>(sdFamilyNames.size())) {
strncpy(SETTINGS.sdFontFamilyName, sdFamilyNames[sdIdx].c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
}
}
};
return s;
}
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline const std::vector<SettingInfo>& getSettingsList() {
static const std::vector<SettingInfo> list = [] {
//
// The static list is constructed exactly once (master's optimization, #1086 +
// #1636) so the per-entry SettingInfo cost is paid once. When an
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
// font-family entry is replaced in a per-call copy with a registry-aware
// version. Callers without SD fonts pay only a vector copy.
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
static const std::vector<SettingInfo> baseList = [] {
std::vector<SettingInfo> v = {
// --- Display ---
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
@@ -40,6 +127,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
StrId::STR_CAT_DISPLAY),
// --- Reader ---
// Built-in font-family entry. Replaced per-call with a registry-aware
// version when SD fonts are installed.
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
StrId::STR_CAT_READER),
@@ -78,6 +167,7 @@ inline const std::vector<SettingInfo>& getSettingsList() {
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
@@ -149,5 +239,13 @@ inline const std::vector<SettingInfo>& getSettingsList() {
}
return v;
}();
return list;
std::vector<SettingInfo> v = baseList;
if (registry && registry->getFamilyCount() > 0) {
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
if (it != v.end()) {
*it = buildFontFamilySetting(registry);
}
}
return v;
}