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
@@ -0,0 +1,230 @@
|
||||
#include "SdCardFontRegistry.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
// --- SdCardFontFamilyInfo helpers ---
|
||||
|
||||
const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t style) const {
|
||||
for (const auto& f : files) {
|
||||
if (f.pointSize == size && f.style == style) return &f;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
|
||||
for (const auto& f : files) {
|
||||
if (f.pointSize == size) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> SdCardFontFamilyInfo::availableSizes() const {
|
||||
std::vector<uint8_t> sizes;
|
||||
for (const auto& f : files) {
|
||||
bool found = false;
|
||||
for (uint8_t s : sizes) {
|
||||
if (s == f.pointSize) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) sizes.push_back(f.pointSize);
|
||||
}
|
||||
std::sort(sizes.begin(), sizes.end());
|
||||
return sizes;
|
||||
}
|
||||
|
||||
// --- SdCardFontRegistry ---
|
||||
|
||||
bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint8_t& style) {
|
||||
// V4 naming: <name>_<size>.cpfont (e.g. Bookerly-SD_14.cpfont)
|
||||
// Use an ends-with check rather than strstr() so that in-progress downloads
|
||||
// like "Foo_14.cpfont.tmp" or backups like "Foo_14.cpfont~" aren't accepted.
|
||||
static constexpr char kExt[] = ".cpfont";
|
||||
static constexpr size_t kExtLen = sizeof(kExt) - 1;
|
||||
const size_t nameLen = strlen(filename);
|
||||
if (nameLen <= kExtLen) return false;
|
||||
if (strcmp(filename + nameLen - kExtLen, kExt) != 0) return false;
|
||||
const char* ext = filename + nameLen - kExtLen;
|
||||
|
||||
size_t baseLen = ext - filename;
|
||||
if (baseLen == 0 || baseLen > 127) return false;
|
||||
|
||||
char base[128];
|
||||
memcpy(base, filename, baseLen);
|
||||
base[baseLen] = '\0';
|
||||
|
||||
char* lastUnderscore = strrchr(base, '_');
|
||||
if (!lastUnderscore || lastUnderscore == base) return false;
|
||||
|
||||
const char* sizeStr = lastUnderscore + 1;
|
||||
char* endPtr;
|
||||
long sizeVal = strtol(sizeStr, &endPtr, 10);
|
||||
if (endPtr == sizeStr || *endPtr != '\0' || sizeVal < 1 || sizeVal > 255) return false;
|
||||
size = static_cast<uint8_t>(sizeVal);
|
||||
// V4 .cpfont files bundle every style (regular/bold/italic/bold-italic) into
|
||||
// one file, so style is always 0 at the registry level. The per-style
|
||||
// bitstream is selected later by SdCardFont::getEpdFont(style). The `style`
|
||||
// field in SdCardFontFileInfo is reserved for future formats that split
|
||||
// styles across files; scanDirectory() defends against accidental
|
||||
// (pointSize, style) collisions in that scenario.
|
||||
style = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) {
|
||||
FsFile dir = Storage.open(dirPath);
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
|
||||
char nameBuffer[128];
|
||||
while (true) {
|
||||
FsFile entry = dir.openNextFile();
|
||||
if (!entry) break;
|
||||
if (entry.isDirectory()) {
|
||||
entry.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.getName(nameBuffer, sizeof(nameBuffer));
|
||||
entry.close();
|
||||
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue;
|
||||
|
||||
uint8_t size, style;
|
||||
if (!parseFilename(nameBuffer, size, style)) continue;
|
||||
|
||||
// Reject duplicate (pointSize, style) entries in the same family. With
|
||||
// v4's bundle-everything design parseFilename always returns style=0, so
|
||||
// two files at the same size in the same family would silently shadow
|
||||
// each other in findFile(). Skip the duplicate and warn.
|
||||
bool duplicate = false;
|
||||
for (const auto& existing : family.files) {
|
||||
if (existing.pointSize == size && existing.style == style) {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (duplicate) {
|
||||
LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
SdCardFontFileInfo info;
|
||||
info.path = std::string(dirPath) + "/" + nameBuffer;
|
||||
info.pointSize = size;
|
||||
info.style = style;
|
||||
family.files.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan a single root (e.g. "/.fonts") and append its families to `out`.
|
||||
// Skips families whose names already exist in `out` (de-duplicates between
|
||||
// the hidden and visible roots — first scan wins).
|
||||
void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector<SdCardFontFamilyInfo>& out) {
|
||||
FsFile root = Storage.open(rootPath);
|
||||
if (!root) {
|
||||
LOG_DBG("SDREG", "Fonts directory not found: %s", rootPath);
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
LOG_ERR("SDREG", "Fonts path is not a directory: %s", rootPath);
|
||||
return;
|
||||
}
|
||||
|
||||
char nameBuffer[128];
|
||||
while (true) {
|
||||
FsFile entry = root.openNextFile();
|
||||
if (!entry) break;
|
||||
if (entry.isDirectory()) {
|
||||
entry.getName(nameBuffer, sizeof(nameBuffer));
|
||||
entry.close();
|
||||
|
||||
// Skip hidden/system directories inside the root (macOS ._*, .Trashes, etc.)
|
||||
if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue;
|
||||
|
||||
// De-dup by family name across roots.
|
||||
bool exists = false;
|
||||
for (const auto& fam : out) {
|
||||
if (fam.name == nameBuffer) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exists) continue;
|
||||
|
||||
SdCardFontFamilyInfo family;
|
||||
family.name = nameBuffer;
|
||||
std::string subDirPath = std::string(rootPath) + "/" + nameBuffer;
|
||||
SdCardFontRegistry::scanDirectory(subDirPath.c_str(), family);
|
||||
|
||||
if (!family.files.empty()) {
|
||||
out.push_back(std::move(family));
|
||||
LOG_DBG("SDREG", "Found family: %s (%d files) in %s", out.back().name.c_str(),
|
||||
static_cast<int>(out.back().files.size()), rootPath);
|
||||
}
|
||||
} else {
|
||||
entry.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SdCardFontRegistry::discover() {
|
||||
families_.clear();
|
||||
families_.reserve(MAX_SD_FAMILIES);
|
||||
|
||||
// Hidden root is scanned first so it wins on name collisions, matching the
|
||||
// sleep-folder pattern (/.sleep preferred over /sleep).
|
||||
scanRoot(FONTS_DIR_HIDDEN, families_);
|
||||
scanRoot(FONTS_DIR_VISIBLE, families_);
|
||||
|
||||
// Sort families alphabetically
|
||||
std::sort(families_.begin(), families_.end(),
|
||||
[](const SdCardFontFamilyInfo& a, const SdCardFontFamilyInfo& b) { return a.name < b.name; });
|
||||
|
||||
// Cap at MAX_SD_FAMILIES
|
||||
if (static_cast<int>(families_.size()) > MAX_SD_FAMILIES) {
|
||||
families_.resize(MAX_SD_FAMILIES);
|
||||
}
|
||||
|
||||
LOG_DBG("SDREG", "Discovery complete: %d families", static_cast<int>(families_.size()));
|
||||
return !families_.empty();
|
||||
}
|
||||
|
||||
const char* SdCardFontRegistry::findFamilyRoot(const char* familyName) {
|
||||
if (!familyName || !*familyName) return nullptr;
|
||||
char path[160];
|
||||
snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_HIDDEN, familyName);
|
||||
if (Storage.exists(path)) return FONTS_DIR_HIDDEN;
|
||||
snprintf(path, sizeof(path), "%s/%s", FONTS_DIR_VISIBLE, familyName);
|
||||
if (Storage.exists(path)) return FONTS_DIR_VISIBLE;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const char* SdCardFontRegistry::defaultWriteRoot() {
|
||||
// If exactly one of the roots already exists, keep using it. Otherwise
|
||||
// (neither exists, or both exist) prefer the hidden root for new installs.
|
||||
bool hiddenExists = Storage.exists(FONTS_DIR_HIDDEN);
|
||||
bool visibleExists = Storage.exists(FONTS_DIR_VISIBLE);
|
||||
if (hiddenExists) return FONTS_DIR_HIDDEN;
|
||||
if (visibleExists) return FONTS_DIR_VISIBLE;
|
||||
return FONTS_DIR_HIDDEN;
|
||||
}
|
||||
|
||||
const SdCardFontFamilyInfo* SdCardFontRegistry::findFamily(const std::string& name) const {
|
||||
for (const auto& f : families_) {
|
||||
if (f.name == name) return &f;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int SdCardFontRegistry::getFamilyIndex(const std::string& name) const {
|
||||
for (int i = 0; i < static_cast<int>(families_.size()); i++) {
|
||||
if (families_[i].name == name) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
Reference in New Issue
Block a user